mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6934432fb5
|
|||
|
|
95197e216c | ||
|
|
660005d857 | ||
|
|
2408ed84bd | ||
|
|
dda8b522e3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ next-env.d.ts
|
||||
/data.*/*
|
||||
Budfile
|
||||
certificates
|
||||
/backups/*
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## Version 0.2.11
|
||||
|
||||
### Added
|
||||
|
||||
* support searching and sorting in habit list
|
||||
|
||||
### Improved
|
||||
|
||||
* Show overdue tasks in daily overview
|
||||
* Context menu option for tasks changed from "Move to Today" to "Move to Tomorrow"
|
||||
* More context menu items in daily overview
|
||||
* code refactor for context menu and daily overview item section
|
||||
|
||||
|
||||
## Version 0.2.10
|
||||
|
||||
### Improved
|
||||
|
||||
* performance optimization: faster load time for large data set
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
16
README.md
16
README.md
@@ -6,7 +6,7 @@ HabitTrove is a gamified habit tracking application that helps you build and mai
|
||||
|
||||
## Try the Demo
|
||||
|
||||
Want to try HabitTrove before installing? Visit the public [demo instance](https://habittrove.app.enting.org) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily)
|
||||
Want to try HabitTrove before installing? Visit the public [demo instance](https://demo.habittrove.com) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
'use server'
|
||||
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
|
||||
import {
|
||||
HabitsData,
|
||||
CoinsData,
|
||||
CoinTransaction,
|
||||
TransactionType,
|
||||
WishlistItemType,
|
||||
WishlistData,
|
||||
Settings,
|
||||
DataType,
|
||||
DATA_DEFAULTS,
|
||||
getDefaultSettings,
|
||||
UserData,
|
||||
getDefaultUsersData,
|
||||
User,
|
||||
getDefaultWishlistData,
|
||||
getDefaultHabitsData,
|
||||
DataType,
|
||||
getDefaultCoinsData,
|
||||
getDefaultHabitsData,
|
||||
getDefaultSettings,
|
||||
getDefaultUsersData,
|
||||
getDefaultWishlistData,
|
||||
HabitsData,
|
||||
Permission,
|
||||
ServerSettings
|
||||
} from '@/lib/types'
|
||||
import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils';
|
||||
import { verifyPassword } from "@/lib/server-helpers";
|
||||
import { saltAndHashPassword } from "@/lib/server-helpers";
|
||||
ServerSettings,
|
||||
Settings,
|
||||
TransactionType,
|
||||
User,
|
||||
UserData,
|
||||
WishlistData,
|
||||
WishlistItemType
|
||||
} from '@/lib/types';
|
||||
import { d2t, getNow, uuid } from '@/lib/utils';
|
||||
import { signInSchema } from '@/lib/zod';
|
||||
import { auth } from '@/auth';
|
||||
import fs from 'fs/promises';
|
||||
import _ from 'lodash';
|
||||
import { getCurrentUser, getCurrentUserId } from '@/lib/server-helpers'
|
||||
import path from 'path';
|
||||
|
||||
import { PermissionError } from '@/lib/exceptions'
|
||||
|
||||
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
||||
type ActionType = 'write' | 'interact'
|
||||
@@ -64,6 +60,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<T>(type: DataType): Promise<T> {
|
||||
try {
|
||||
await ensureDataDir()
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import HabitCalendar from '@/components/HabitCalendar'
|
||||
import { ViewToggle } from '@/components/ViewToggle'
|
||||
import CompletionCountBadge from '@/components/CompletionCountBadge'
|
||||
|
||||
export default function CalendarPage() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import CoinsManager from '@/components/CoinsManager'
|
||||
|
||||
export default function CoinsPage() {
|
||||
|
||||
60
app/debug/backup/page.tsx
Normal file
60
app/debug/backup/page.tsx
Normal file
@@ -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 (
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold mb-4">Debug Backup</h1>
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-4 rounded space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleBackupClick}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Running Backup...
|
||||
</>
|
||||
) : (
|
||||
'Run Manual Backup Now'
|
||||
)}
|
||||
</Button>
|
||||
{statusMessage && (
|
||||
<div className={`mt-4 p-3 rounded ${isError ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200'}`}>
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useHabits } from "@/hooks/useHabits";
|
||||
import { habitsAtom, settingsAtom } from "@/lib/atoms";
|
||||
import { Habit } from "@/lib/types";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import HabitList from '@/components/HabitList'
|
||||
import { ViewToggle } from '@/components/ViewToggle'
|
||||
|
||||
export default function HabitsPage() {
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import './globals.css'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { DM_Sans } from 'next/font/google'
|
||||
import { JotaiProvider } from '@/components/jotai-providers'
|
||||
import { Suspense } from 'react'
|
||||
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
|
||||
import { JotaiProvider } from '@/components/jotai-providers'
|
||||
import Layout from '@/components/Layout'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import { DM_Sans } from 'next/font/google'
|
||||
import { Suspense } from 'react'
|
||||
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
|
||||
import './globals.css'
|
||||
|
||||
|
||||
// Inter (clean, modern, excellent readability)
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
'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 { 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 { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { settingsAtom } from '@/lib/atoms';
|
||||
import { Settings, WeekDay } from '@/lib/types';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Info } from 'lucide-react'; // Import Info icon
|
||||
import { saveSettings } from '../actions/data';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useAtom(settingsAtom)
|
||||
const [settings, setSettings] = useAtom(settingsAtom);
|
||||
|
||||
const updateSettings = async (newSettings: Settings) => {
|
||||
await saveSettings(newSettings)
|
||||
@@ -140,6 +144,46 @@ export default function SettingsPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add this section for Auto Backup */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="auto-backup">Auto Backup</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p className="max-w-xs text-sm">
|
||||
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.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Automatically back up data daily
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-backup"
|
||||
checked={settings.system.autoBackupEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings({
|
||||
...settings,
|
||||
system: { ...settings.system, autoBackupEnabled: checked }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* End of Auto Backup section */}
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div >
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import WishlistManager from '@/components/WishlistManager'
|
||||
|
||||
export default function WishlistPage() {
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { RRule, RRuleSet, rrulestr } from 'rrule'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Info, SmilePlus, Zap } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { settingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES } from '@/lib/constants'
|
||||
import { Habit } from '@/lib/types'
|
||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import { Habit, SafeUser } from '@/lib/types'
|
||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
|
||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
||||
import * as chrono from 'chrono-node';
|
||||
import { useAtom } from 'jotai'
|
||||
import { SmilePlus, Zap } from 'lucide-react'
|
||||
import { DateTime } from 'luxon'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { useState } from 'react'
|
||||
import { RRule } from 'rrule'
|
||||
|
||||
interface AddEditHabitModalProps {
|
||||
onClose: () => void
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { usersAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { SmilePlus, Info } from 'lucide-react'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usersAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { SmilePlus } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
|
||||
interface AddEditWishlistItemModalProps {
|
||||
isOpen: boolean
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
|
||||
if (!currentUserId && !userSelect) {
|
||||
setUserSelect(true)
|
||||
}
|
||||
}, [currentUserId, status, userSelect])
|
||||
}, [currentUserId, status, userSelect, setUserSelect])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react' // Import useEffect, useRef
|
||||
import { useSearchParams } from 'next/navigation' // Import useSearchParams
|
||||
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
import { History, Pencil } from 'lucide-react'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import EmptyState from './EmptyState'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { settingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import Link from 'next/link'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
||||
import { settingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { d2s, t2d } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { History } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'; // Import useSearchParams
|
||||
import { useEffect, useRef, useState } from 'react'; // Import useEffect, useRef
|
||||
import EmptyState from './EmptyState'
|
||||
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
||||
|
||||
export default function CoinsManager() {
|
||||
const { currentUser } = useHelpers()
|
||||
@@ -53,7 +53,7 @@ export default function CoinsManager() {
|
||||
}
|
||||
}
|
||||
// Only run when userIdFromQuery or currentUser changes, avoid re-running on selectedUser change within this effect
|
||||
}, [userIdFromQuery, currentUser, usersData.users]);
|
||||
}, [userIdFromQuery, currentUser, usersData.users, selectedUser]);
|
||||
|
||||
// Effect to scroll to highlighted transaction
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useAtom } from 'jotai'
|
||||
import { completedHabitsMapAtom, habitsAtom, habitsByDateFamily } from '@/lib/atoms'
|
||||
import { completedHabitsMapAtom, habitsByDateFamily, settingsAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone } from '@/lib/utils'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
import { useAtom } from 'jotai'
|
||||
|
||||
interface CompletionCountBadgeProps {
|
||||
type: 'habits' | 'tasks'
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus, Pin, Calendar } from 'lucide-react'
|
||||
import CompletionCountBadge from './CompletionCountBadge'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuTrigger
|
||||
} from "@/components/ui/context-menu"
|
||||
import { cn, getHabitFreq } from '@/lib/utils'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { Habit, WishlistItemType } from '@/lib/types'
|
||||
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react'; // Removed unused icons
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom, dailyHabitsAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Settings, WishlistItemType } from '@/lib/types'
|
||||
import { Habit } from '@/lib/types'
|
||||
import Linkify from './linkify'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import AddEditHabitModal from './AddEditHabitModal'
|
||||
import CompletionCountBadge from './CompletionCountBadge'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||
import Linkify from './linkify'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
interface UpcomingItemsProps {
|
||||
@@ -34,13 +39,7 @@ interface ItemSectionProps {
|
||||
emptyMessage: string;
|
||||
isTask: boolean;
|
||||
viewLink: string;
|
||||
expanded: boolean;
|
||||
setExpanded: (value: boolean) => void;
|
||||
addNewItem: () => void;
|
||||
badgeType: "tasks" | "habits";
|
||||
todayCompletions: Habit[];
|
||||
settings: Settings;
|
||||
setBrowserSettings: (value: React.SetStateAction<BrowserSettings>) => void;
|
||||
}
|
||||
|
||||
const ItemSection = ({
|
||||
@@ -49,16 +48,46 @@ const ItemSection = ({
|
||||
emptyMessage,
|
||||
isTask,
|
||||
viewLink,
|
||||
expanded,
|
||||
setExpanded,
|
||||
addNewItem,
|
||||
badgeType,
|
||||
todayCompletions,
|
||||
settings,
|
||||
setBrowserSettings,
|
||||
}: ItemSectionProps) => {
|
||||
const { completeHabit, undoComplete, saveHabit } = useHabits();
|
||||
const { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, habitFreqMap } = useHabits();
|
||||
const [_, setPomo] = useAtom(pomodoroAtom);
|
||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
|
||||
const [settings] = useAtom(settingsAtom);
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom);
|
||||
|
||||
const today = getTodayInTimezone(settings.system.timezone);
|
||||
const currentTodayCompletions = completedHabitsMap.get(today) || [];
|
||||
const currentBadgeType = isTask ? 'tasks' : 'habits';
|
||||
|
||||
const currentExpanded = isTask ? browserSettings.expandedTasks : browserSettings.expandedHabits;
|
||||
const setCurrentExpanded = (value: boolean) => {
|
||||
setBrowserSettings(prev => ({
|
||||
...prev,
|
||||
[isTask ? 'expandedTasks' : 'expandedHabits']: value
|
||||
}));
|
||||
};
|
||||
|
||||
const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = useState(false);
|
||||
const [habitToDelete, setHabitToDelete] = useState<Habit | null>(null);
|
||||
const [habitToEdit, setHabitToEdit] = useState<Habit | null>(null);
|
||||
|
||||
const handleDeleteClick = (habit: Habit) => {
|
||||
setHabitToDelete(habit);
|
||||
setIsConfirmDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (habitToDelete) {
|
||||
await deleteHabit(habitToDelete.id);
|
||||
setHabitToDelete(null);
|
||||
setIsConfirmDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = (habit: Habit) => {
|
||||
setHabitToEdit(habit);
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
@@ -89,7 +118,7 @@ const ItemSection = ({
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CompletionCountBadge type={badgeType} />
|
||||
<CompletionCountBadge type={currentBadgeType} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -101,7 +130,7 @@ const ItemSection = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expanded ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${currentExpanded ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
{items
|
||||
.sort((a, b) => {
|
||||
// First by pinned status
|
||||
@@ -110,15 +139,15 @@ const ItemSection = ({
|
||||
}
|
||||
|
||||
// Then by completion status
|
||||
const aCompleted = todayCompletions.includes(a);
|
||||
const bCompleted = todayCompletions.includes(b);
|
||||
const aCompleted = currentTodayCompletions.includes(a);
|
||||
const bCompleted = currentTodayCompletions.includes(b);
|
||||
if (aCompleted !== bCompleted) {
|
||||
return aCompleted ? 1 : -1;
|
||||
}
|
||||
|
||||
// Then by frequency (daily first)
|
||||
const aFreq = getHabitFreq(a);
|
||||
const bFreq = getHabitFreq(b);
|
||||
const aFreq = habitFreqMap.get(a.id) || 'daily';
|
||||
const bFreq = habitFreqMap.get(b.id) || 'daily';
|
||||
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
|
||||
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
|
||||
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
|
||||
@@ -134,7 +163,7 @@ const ItemSection = ({
|
||||
const bTarget = b.targetCompletions || 1;
|
||||
return bTarget - aTarget;
|
||||
})
|
||||
.slice(0, expanded ? undefined : 5)
|
||||
.slice(0, currentExpanded ? undefined : 5)
|
||||
.map((habit) => {
|
||||
const completionsToday = habit.completions.filter(completion =>
|
||||
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
||||
@@ -190,50 +219,46 @@ const ItemSection = ({
|
||||
)}
|
||||
<Link
|
||||
href={`/habits?highlight=${habit.id}`}
|
||||
className={cn(
|
||||
isCompleted ? 'line-through' : '',
|
||||
'break-all hover:text-primary transition-colors'
|
||||
)}
|
||||
className="flex items-center gap-1 hover:text-primary transition-colors"
|
||||
onClick={() => {
|
||||
const newViewType = isTask ? 'tasks' : 'habits';
|
||||
if (browserSettings.viewType !== newViewType) {
|
||||
setBrowserSettings(prev => ({ ...prev, viewType: newViewType }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{habit.name}
|
||||
{isTask && isTaskOverdue(habit, settings.system.timezone) && !isCompleted && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
{/* The AlertTriangle itself doesn't need hover styles if the parent Link handles it */}
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Overdue</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
isCompleted ? 'line-through' : '',
|
||||
'break-all' // Text specific styles
|
||||
)}
|
||||
>
|
||||
{habit.name}
|
||||
</span>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-64">
|
||||
<ContextMenuItem onClick={() => {
|
||||
setPomo((prev) => ({
|
||||
...prev,
|
||||
show: true,
|
||||
selectedHabitId: habit.id
|
||||
}))
|
||||
}}>
|
||||
<Timer className="mr-2 h-4 w-4" />
|
||||
<span>Start Pomodoro</span>
|
||||
</ContextMenuItem>
|
||||
{habit.isTask && (
|
||||
<ContextMenuItem onClick={() => {
|
||||
saveHabit({...habit, frequency: d2t({ dateTime: getNow({ timezone: settings.system.timezone })})})
|
||||
}}>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<span>Move to Today</span>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => {
|
||||
saveHabit({ ...habit, pinned: !habit.pinned })
|
||||
}}>
|
||||
{habit.pinned ? (
|
||||
<>
|
||||
<Pin className="mr-2 h-4 w-4" />
|
||||
<span>Unpin</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin className="mr-2 h-4 w-4" />
|
||||
<span>Pin</span>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
<HabitContextMenuItems
|
||||
habit={habit}
|
||||
onEditRequest={() => handleEditClick(habit)}
|
||||
onDeleteRequest={() => handleDeleteClick(habit)}
|
||||
context="daily-overview"
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</span>
|
||||
@@ -243,9 +268,9 @@ const ItemSection = ({
|
||||
{completionsToday}/{target}
|
||||
</span>
|
||||
)}
|
||||
{getHabitFreq(habit) !== 'daily' && (
|
||||
{habitFreqMap.get(habit.id) !== 'daily' && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getHabitFreq(habit)}
|
||||
{habitFreqMap.get(habit.id)}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="flex items-center">
|
||||
@@ -271,10 +296,10 @@ const ItemSection = ({
|
||||
</ul>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={() => setCurrentExpanded(!currentExpanded)}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
{expanded ? (
|
||||
{currentExpanded ? (
|
||||
<>
|
||||
Show less
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
@@ -290,10 +315,9 @@ const ItemSection = ({
|
||||
href={viewLink}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
onClick={() => {
|
||||
if (isTask) {
|
||||
setBrowserSettings(prev => ({ ...prev, viewType: 'tasks' }));
|
||||
} else {
|
||||
setBrowserSettings(prev => ({ ...prev, viewType: 'habits' }));
|
||||
const newViewType = isTask ? 'tasks' : 'habits';
|
||||
if (browserSettings.viewType !== newViewType) {
|
||||
setBrowserSettings(prev => ({ ...prev, viewType: newViewType }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -301,6 +325,27 @@ const ItemSection = ({
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
{habitToDelete && (
|
||||
<ConfirmDialog
|
||||
isOpen={isConfirmDeleteDialogOpen}
|
||||
onClose={() => setIsConfirmDeleteDialogOpen(false)}
|
||||
onConfirm={confirmDelete}
|
||||
title={`Delete ${isTask ? 'Task' : 'Habit'}`}
|
||||
message={`Are you sure you want to delete "${habitToDelete.name}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
/>
|
||||
)}
|
||||
{habitToEdit && (
|
||||
<AddEditHabitModal
|
||||
onClose={() => setHabitToEdit(null)}
|
||||
onSave={async (updatedHabit) => {
|
||||
await saveHabit({ ...habitToEdit, ...updatedHabit });
|
||||
setHabitToEdit(null);
|
||||
}}
|
||||
habit={habitToEdit}
|
||||
isTask={habitToEdit.isTask || false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -313,14 +358,25 @@ export default function DailyOverview({
|
||||
const { completeHabit, undoComplete } = useHabits()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||
const [dailyItems] = useAtom(dailyHabitsAtom)
|
||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
||||
const dailyTasks = dailyItems.filter(habit => habit.isTask)
|
||||
const dailyHabits = dailyItems.filter(habit => !habit.isTask)
|
||||
const today = getTodayInTimezone(settings.system.timezone)
|
||||
const todayCompletions = completedHabitsMap.get(today) || []
|
||||
const { saveHabit } = useHabits()
|
||||
|
||||
const timezone = settings.system.timezone
|
||||
const todayDateObj = getNow({ timezone })
|
||||
|
||||
const dailyTasks = habits.filter(habit =>
|
||||
habit.isTask &&
|
||||
!habit.archived &&
|
||||
(isHabitDue({ habit, timezone, date: todayDateObj }) || isTaskOverdue(habit, timezone))
|
||||
)
|
||||
const dailyHabits = habits.filter(habit =>
|
||||
!habit.isTask &&
|
||||
!habit.archived &&
|
||||
isHabitDue({ habit, timezone, date: todayDateObj })
|
||||
)
|
||||
|
||||
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
|
||||
// Filter out archived wishlist items
|
||||
const sortedWishlistItems = wishlistItems
|
||||
@@ -364,13 +420,7 @@ export default function DailyOverview({
|
||||
emptyMessage="No tasks due today. Add some tasks to get started!"
|
||||
isTask={true}
|
||||
viewLink="/habits?view=tasks"
|
||||
expanded={browserSettings.expandedTasks}
|
||||
setExpanded={(value) => setBrowserSettings(prev => ({ ...prev, expandedTasks: value }))}
|
||||
addNewItem={() => setModalConfig({ isOpen: true, isTask: true })}
|
||||
badgeType="tasks"
|
||||
todayCompletions={todayCompletions}
|
||||
settings={settings}
|
||||
setBrowserSettings={setBrowserSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -381,13 +431,7 @@ export default function DailyOverview({
|
||||
emptyMessage="No habits due today. Add some habits to get started!"
|
||||
isTask={false}
|
||||
viewLink="/habits"
|
||||
expanded={browserSettings.expandedHabits}
|
||||
setExpanded={(value) => setBrowserSettings(prev => ({ ...prev, expandedHabits: value }))}
|
||||
addNewItem={() => setModalConfig({ isOpen: true, isTask: false })}
|
||||
badgeType="habits"
|
||||
todayCompletions={todayCompletions}
|
||||
settings={settings}
|
||||
setBrowserSettings={setBrowserSettings}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { habitsAtom, wishlistAtom } from '@/lib/atoms'
|
||||
import { useAtom } from 'jotai'
|
||||
import { wishlistAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import CoinBalance from './CoinBalance'
|
||||
import DailyOverview from './DailyOverview'
|
||||
import HabitStreak from './HabitStreak'
|
||||
import CoinBalance from './CoinBalance'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [habitsData] = useAtom(habitsAtom)
|
||||
const habits = habitsData.habits
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const { balance } = useCoins()
|
||||
const [wishlist] = useAtom(wishlistAtom)
|
||||
const wishlistItems = wishlist.items
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import CompletionCountBadge from '@/components/CompletionCountBadge'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import CompletionCountBadge from '@/components/CompletionCountBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, Circle, CircleCheck } from 'lucide-react'
|
||||
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { habitsAtom, settingsAtom, completedHabitsMapAtom, hasTasksAtom } from '@/lib/atoms'
|
||||
import { DateTime } from 'luxon'
|
||||
import Linkify from './linkify'
|
||||
import { completedHabitsMapAtom, habitsAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { Habit } from '@/lib/types'
|
||||
import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Circle, CircleCheck } from 'lucide-react'
|
||||
import { DateTime } from 'luxon'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Linkify from './linkify'
|
||||
|
||||
export default function HabitCalendar() {
|
||||
const { completePastHabit } = useHabits()
|
||||
|
||||
157
components/HabitContextMenuItems.tsx
Normal file
157
components/HabitContextMenuItems.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Habit } from '@/lib/types';
|
||||
import { useHabits } from '@/hooks/useHabits';
|
||||
import { useAtom } from 'jotai';
|
||||
import { pomodoroAtom, settingsAtom } from '@/lib/atoms';
|
||||
import { d2t, getNow, isHabitDueToday } from '@/lib/utils';
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
||||
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
||||
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
|
||||
import { useHelpers } from '@/lib/client-helpers'; // For permission checks if needed, though useHabits handles most
|
||||
|
||||
interface HabitContextMenuItemsProps {
|
||||
habit: Habit;
|
||||
onEditRequest: () => void;
|
||||
onDeleteRequest: () => void;
|
||||
context?: 'daily-overview' | 'habit-item';
|
||||
onClose?: () => void; // Optional: To close the dropdown if an action is taken
|
||||
}
|
||||
|
||||
export function HabitContextMenuItems({
|
||||
habit,
|
||||
onEditRequest,
|
||||
onDeleteRequest,
|
||||
context = 'habit-item',
|
||||
onClose,
|
||||
}: HabitContextMenuItemsProps) {
|
||||
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
|
||||
const [settings] = useAtom(settingsAtom);
|
||||
const [, setPomo] = useAtom(pomodoroAtom);
|
||||
const { hasPermission } = useHelpers(); // Assuming useHabits handles permissions for its actions
|
||||
|
||||
const canWrite = hasPermission('habit', 'write'); // For UI disabling if not handled by useHabits' actions
|
||||
const canInteract = hasPermission('habit', 'interact');
|
||||
|
||||
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
|
||||
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;
|
||||
|
||||
const taskIsDueToday = habit.isTask ? isHabitDueToday({ habit, timezone: settings.system.timezone }) : false;
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!habit.archived && (
|
||||
<MenuItemComponent
|
||||
disabled={!canInteract}
|
||||
onClick={() => handleAction(() => {
|
||||
setPomo((prev) => ({
|
||||
...prev,
|
||||
show: true,
|
||||
selectedHabitId: habit.id,
|
||||
}));
|
||||
})}
|
||||
>
|
||||
<Timer className="mr-2 h-4 w-4" />
|
||||
<span>Start Pomodoro</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
{/* "Move to Today" option: Show if task is not due today */}
|
||||
{habit.isTask && !habit.archived && !taskIsDueToday && (
|
||||
<MenuItemComponent
|
||||
disabled={!canWrite}
|
||||
onClick={() => handleAction(() => {
|
||||
const today = getNow({ timezone: settings.system.timezone });
|
||||
saveHabit({ ...habit, frequency: d2t({ dateTime: today }) });
|
||||
})}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<span>Move to Today</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
{/* "Move to Tomorrow" option: Show if task is due today OR not due today */}
|
||||
{habit.isTask && !habit.archived && (
|
||||
<MenuItemComponent
|
||||
disabled={!canWrite}
|
||||
onClick={() => handleAction(() => {
|
||||
const tomorrow = getNow({ timezone: settings.system.timezone }).plus({ days: 1 });
|
||||
saveHabit({ ...habit, frequency: d2t({ dateTime: tomorrow }) });
|
||||
})}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<span>Move to Tomorrow</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
{!habit.archived && (
|
||||
<MenuItemComponent
|
||||
disabled={!canWrite}
|
||||
onClick={() => handleAction(() => saveHabit({ ...habit, pinned: !habit.pinned }))}
|
||||
>
|
||||
<Pin className="mr-2 h-4 w-4" />
|
||||
<span>{habit.pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
{context === 'habit-item' && !habit.archived && ( // Edit button visible in dropdown only for habit-item context on small screens
|
||||
<MenuItemComponent
|
||||
onClick={() => handleAction(onEditRequest)}
|
||||
className="sm:hidden" // Kept the sm:hidden for HabitItem specific responsive behavior
|
||||
disabled={!canWrite}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
{context === 'daily-overview' && !habit.archived && ( // Edit button always visible in dropdown for daily-overview context
|
||||
<MenuItemComponent
|
||||
onClick={() => handleAction(onEditRequest)}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
|
||||
{!habit.archived && (
|
||||
<MenuItemComponent
|
||||
disabled={!canWrite}
|
||||
onClick={() => handleAction(() => archiveHabit(habit.id))}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
<span>Archive</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
{habit.archived && (
|
||||
<MenuItemComponent
|
||||
disabled={!canWrite}
|
||||
onClick={() => handleAction(() => unarchiveHabit(habit.id))}
|
||||
>
|
||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||
<span>Unarchive</span>
|
||||
</MenuItemComponent>
|
||||
)}
|
||||
|
||||
{context === 'habit-item' && !habit.archived && <MenuSeparatorComponent className="sm:hidden" />}
|
||||
|
||||
{(context === 'daily-overview' || habit.archived) && <MenuSeparatorComponent />}
|
||||
|
||||
|
||||
<MenuItemComponent
|
||||
onClick={() => handleAction(onDeleteRequest)}
|
||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||
disabled={!canWrite} // Assuming delete is a write operation
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</MenuItemComponent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,20 @@
|
||||
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar, Pin } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
||||
import { DateTime } from 'luxon'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
import { browserSettingsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { Habit, User } from '@/lib/types'
|
||||
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, isTaskOverdue } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'; // Removed unused icons
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
|
||||
interface HabitItemProps {
|
||||
habit: Habit
|
||||
@@ -48,7 +45,6 @@ const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: {
|
||||
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [_, setPomo] = useAtom(pomodoroAtom)
|
||||
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
|
||||
const target = habit.targetCompletions || 1
|
||||
const isCompletedToday = completionsToday >= target
|
||||
@@ -194,70 +190,12 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!habit.archived && (
|
||||
<DropdownMenuItem onClick={() => {
|
||||
if (!canInteract) return
|
||||
setPomo((prev) => ({
|
||||
...prev,
|
||||
show: true,
|
||||
selectedHabitId: habit.id
|
||||
}))
|
||||
}}>
|
||||
<Timer className="mr-2 h-4 w-4" />
|
||||
<span>Start Pomodoro</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!habit.archived && (
|
||||
<>
|
||||
{habit.isTask && (
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => {
|
||||
saveHabit({...habit, frequency: d2t({ dateTime: getNow({ timezone: settings.system.timezone })})})
|
||||
}}>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<span>Move to Today</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => saveHabit({...habit, pinned: !habit.pinned})}>
|
||||
{habit.pinned ? (
|
||||
<>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
<span>Unpin</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
<span>Pin</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => archiveHabit(habit.id)}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
<span>Archive</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{habit.archived && (
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => unarchiveHabit(habit.id)}>
|
||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||
<span>Unarchive</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={onEdit}
|
||||
className="sm:hidden"
|
||||
disabled={habit.archived}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="sm:hidden" />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<HabitContextMenuItems
|
||||
habit={habit}
|
||||
onEditRequest={onEdit}
|
||||
onDeleteRequest={onDelete}
|
||||
context="habit-item"
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Plus, ListTodo } from 'lucide-react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||
import EmptyState from './EmptyState'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import HabitItem from './HabitItem'
|
||||
import { Input } from '@/components/ui/input'; // Added
|
||||
import { Label } from '@/components/ui/label'; // Added
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Added
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { browserSettingsAtom, habitsAtom } from '@/lib/atoms'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { Habit } from '@/lib/types'
|
||||
import { getHabitFreq } from '@/lib/utils'; // Added
|
||||
import { useAtom } from 'jotai'
|
||||
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Plus, Search } from 'lucide-react'; // Added sort icons, Search icon
|
||||
import { DateTime } from 'luxon'; // Added
|
||||
import { useEffect, useMemo, useState } from 'react'; // Added useMemo, useEffect
|
||||
import AddEditHabitModal from './AddEditHabitModal'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { Habit } from '@/lib/types'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import EmptyState from './EmptyState'
|
||||
import HabitItem from './HabitItem'
|
||||
import { ViewToggle } from './ViewToggle'
|
||||
|
||||
export default function HabitList() {
|
||||
const { saveHabit, deleteHabit } = useHabits()
|
||||
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
||||
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const habits = habitsData.habits.filter(habit =>
|
||||
isTasksView ? habit.isTask : !habit.isTask
|
||||
)
|
||||
const activeHabits = habits
|
||||
.filter(h => !h.archived)
|
||||
.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0))
|
||||
const archivedHabits = habits.filter(h => h.archived)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
// const [settings] = useAtom(settingsAtom); // settingsAtom is not directly used in HabitList itself.
|
||||
|
||||
type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortableField>('name');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
|
||||
useEffect(() => {
|
||||
if (isTasksView && sortBy === 'frequency') {
|
||||
setSortBy('name');
|
||||
} else if (!isTasksView && sortBy === 'dueDate') {
|
||||
setSortBy('name');
|
||||
}
|
||||
}, [isTasksView, sortBy]);
|
||||
|
||||
const compareHabits = useMemo(() => {
|
||||
return (a: Habit, b: Habit, currentSortBy: SortableField, currentSortOrder: SortOrder, tasksView: boolean): number => {
|
||||
let comparison = 0;
|
||||
switch (currentSortBy) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'coinReward':
|
||||
comparison = a.coinReward - b.coinReward;
|
||||
break;
|
||||
case 'dueDate':
|
||||
if (tasksView && a.isTask && b.isTask) {
|
||||
const dateA = DateTime.fromISO(a.frequency);
|
||||
const dateB = DateTime.fromISO(b.frequency);
|
||||
if (dateA.isValid && dateB.isValid) comparison = dateA.toMillis() - dateB.toMillis();
|
||||
else if (dateA.isValid) comparison = -1; // Valid dates first
|
||||
else if (dateB.isValid) comparison = 1;
|
||||
// If both invalid, comparison remains 0
|
||||
}
|
||||
break;
|
||||
case 'frequency':
|
||||
if (!tasksView && !a.isTask && !b.isTask) {
|
||||
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
|
||||
const freqAVal = getHabitFreq(a);
|
||||
const freqBVal = getHabitFreq(b);
|
||||
comparison = freqOrder.indexOf(freqAVal) - freqOrder.indexOf(freqBVal);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return currentSortOrder === 'asc' ? comparison : -comparison;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const allHabitsInView = useMemo(() => {
|
||||
return habitsData.habits.filter(habit =>
|
||||
isTasksView ? habit.isTask : !habit.isTask
|
||||
);
|
||||
}, [habitsData.habits, isTasksView]);
|
||||
|
||||
const searchedHabits = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return allHabitsInView;
|
||||
}
|
||||
const lowercasedSearchTerm = searchTerm.toLowerCase();
|
||||
return allHabitsInView.filter(habit =>
|
||||
habit.name.toLowerCase().includes(lowercasedSearchTerm) ||
|
||||
(habit.description && habit.description.toLowerCase().includes(lowercasedSearchTerm))
|
||||
);
|
||||
}, [allHabitsInView, searchTerm]);
|
||||
|
||||
const activeHabits = useMemo(() => {
|
||||
return searchedHabits
|
||||
.filter(h => !h.archived)
|
||||
.sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
// For items in the same pinned group (both pinned or both not pinned), apply general sort
|
||||
return compareHabits(a, b, sortBy, sortOrder, isTasksView);
|
||||
});
|
||||
}, [searchedHabits, sortBy, sortOrder, isTasksView, compareHabits]);
|
||||
|
||||
const archivedHabits = useMemo(() => {
|
||||
return searchedHabits
|
||||
.filter(h => h.archived)
|
||||
.sort((a, b) => compareHabits(a, b, sortBy, sortOrder, isTasksView));
|
||||
}, [searchedHabits, sortBy, sortOrder, isTasksView, compareHabits]);
|
||||
const [modalConfig, setModalConfig] = useState<{
|
||||
isOpen: boolean,
|
||||
isTask: boolean
|
||||
@@ -59,8 +139,47 @@ export default function HabitList() {
|
||||
<div className='py-4'>
|
||||
<ViewToggle />
|
||||
</div>
|
||||
|
||||
{/* Search and Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 my-4">
|
||||
<div className="relative flex-grow w-full sm:w-auto">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`Search ${isTasksView ? 'tasks' : 'habits'}...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start sm:self-center w-full sm:w-auto">
|
||||
<Label htmlFor="sort-by" className="text-sm font-medium whitespace-nowrap sr-only sm:not-sr-only">Sort by:</Label>
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortableField)}>
|
||||
<SelectTrigger id="sort-by" className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="coinReward">Coin Reward</SelectItem>
|
||||
{isTasksView && <SelectItem value="dueDate">Due Date</SelectItem>}
|
||||
{!isTasksView && <SelectItem value="frequency">Frequency</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}>
|
||||
{sortOrder === 'asc' ? <ArrowUpNarrowWide className="h-4 w-4" /> : <ArrowDownWideNarrow className="h-4 w-4" />}
|
||||
<span className="sr-only">Toggle sort order</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||
{activeHabits.length === 0 ? (
|
||||
{activeHabits.length === 0 && searchTerm.trim() ? (
|
||||
<div className="col-span-2 text-center text-muted-foreground py-8">
|
||||
No {isTasksView ? 'tasks' : 'habits'} found matching your search.
|
||||
</div>
|
||||
) : activeHabits.length === 0 ? (
|
||||
<div className="col-span-2">
|
||||
<EmptyState
|
||||
icon={isTasksView ? TaskIcon : HabitIcon}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { Habit } from '@/lib/types'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { d2s, getNow, t2d, getCompletedHabitsForDate } from '@/lib/utils'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom
|
||||
import { Habit } from '@/lib/types'
|
||||
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom, hasTasksAtom } from '@/lib/atoms'
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
|
||||
interface HabitStreakProps {
|
||||
habits: Habit[]
|
||||
@@ -14,6 +14,8 @@ interface HabitStreakProps {
|
||||
export default function HabitStreak({ habits }: HabitStreakProps) {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [hasTasks] = useAtom(hasTasksAtom)
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
|
||||
|
||||
// Get the last 7 days of data
|
||||
const dates = Array.from({ length: 7 }, (_, i) => {
|
||||
const d = getNow({ timezone: settings.system.timezone });
|
||||
@@ -21,20 +23,17 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
||||
}).reverse()
|
||||
|
||||
const completions = dates.map(date => {
|
||||
const completedHabits = getCompletedHabitsForDate({
|
||||
habits: habits.filter(h => !h.isTask),
|
||||
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
|
||||
timezone: settings.system.timezone
|
||||
});
|
||||
const completedTasks = getCompletedHabitsForDate({
|
||||
habits: habits.filter(h => h.isTask),
|
||||
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
|
||||
timezone: settings.system.timezone
|
||||
});
|
||||
// Get completed habits for the date from the map
|
||||
const completedOnDate = completedHabitsMap.get(date) || [];
|
||||
|
||||
// Filter the completed list to count habits and tasks
|
||||
const completedHabitsCount = completedOnDate.filter(h => !h.isTask).length;
|
||||
const completedTasksCount = completedOnDate.filter(h => h.isTask).length;
|
||||
|
||||
return {
|
||||
date,
|
||||
habits: completedHabits.length,
|
||||
tasks: completedTasks.length
|
||||
habits: completedHabitsCount,
|
||||
tasks: completedTasksCount
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
import { Menu, Settings, User, Info, Coins } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import NotificationBell from './NotificationBell'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import AboutModal from './AboutModal'
|
||||
import Link from 'next/link'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Coins } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import NotificationBell from './NotificationBell'
|
||||
import { Profile } from './Profile'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
|
||||
interface HeaderProps {
|
||||
className?: string
|
||||
@@ -30,7 +19,6 @@ const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: fals
|
||||
|
||||
export default function Header({ className }: HeaderProps) {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const { balance } = useCoins()
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Sparkles } from "lucide-react"
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AboutModal from './AboutModal'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
|
||||
type ViewPort = 'main' | 'mobile'
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { CoinsData, HabitsData, WishlistData, UserData, User, CoinTransaction } from '@/lib/types';
|
||||
import { t2d } from '@/lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { Info } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types';
|
||||
import { t2d } from '@/lib/utils';
|
||||
import { Info } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface NotificationDropdownProps {
|
||||
currentUser: User | null;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { Label } from './ui/label';
|
||||
import { User as UserIcon } from 'lucide-react';
|
||||
import { Permission, User } from '@/lib/types';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { User } from '@/lib/types';
|
||||
import { User as UserIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
interface PasswordEntryFormProps {
|
||||
user: User;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Play, Pause, RotateCw, Minus, X, Clock, SkipForward } from 'lucide-react'
|
||||
import { cn, getCompletionsForToday } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom, pomodoroAtom, habitsAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
|
||||
import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface PomoConfig {
|
||||
labels: string[]
|
||||
@@ -135,7 +134,19 @@ export default function PomodoroTimer() {
|
||||
const remaining = Math.floor((targetEndTime - Date.now()) / 1000)
|
||||
|
||||
if (remaining <= 0) {
|
||||
handleTimerEnd()
|
||||
setState("stopped")
|
||||
const currentTimerType = currentTimer.current.type
|
||||
currentTimer.current = currentTimerType === 'focus' ? PomoConfigs.break : PomoConfigs.focus
|
||||
setTimeLeft(currentTimer.current.duration)
|
||||
setCurrentLabel(
|
||||
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
||||
)
|
||||
|
||||
// update habits only after focus sessions
|
||||
if (selectedHabit && currentTimerType === 'focus') {
|
||||
completeHabit(selectedHabit)
|
||||
// The atom will automatically update with the new completions
|
||||
}
|
||||
} else {
|
||||
setTimeLeft(remaining)
|
||||
}
|
||||
@@ -146,23 +157,7 @@ export default function PomodoroTimer() {
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const handleTimerEnd = async () => {
|
||||
setState("stopped")
|
||||
const currentTimerType = currentTimer.current.type
|
||||
currentTimer.current = currentTimerType === 'focus' ? PomoConfigs.break : PomoConfigs.focus
|
||||
setTimeLeft(currentTimer.current.duration)
|
||||
setCurrentLabel(
|
||||
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
|
||||
)
|
||||
|
||||
// update habits only after focus sessions
|
||||
if (selectedHabit && currentTimerType === 'focus') {
|
||||
await completeHabit(selectedHabit)
|
||||
// The atom will automatically update with the new completions
|
||||
}
|
||||
}
|
||||
}, [state, timeLeft, completeHabit, selectedHabit])
|
||||
|
||||
const toggleTimer = () => {
|
||||
setState(prev => prev === 'started' ? 'paused' : 'started')
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { signOut } from "@/app/actions/user"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Settings, Info, User, Moon, Sun, Palette, ArrowRightLeft, LogOut, Crown } from "lucide-react"
|
||||
import { toast } from "@/hooks/use-toast"
|
||||
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
|
||||
import { useHelpers } from "@/lib/client-helpers"
|
||||
import { useAtom } from "jotai"
|
||||
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import AboutModal from "./AboutModal"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import UserForm from './UserForm'
|
||||
import Link from "next/link"
|
||||
import { useAtom } from "jotai"
|
||||
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
|
||||
import AboutModal from "./AboutModal"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { signOut } from "@/app/actions/user"
|
||||
import { toast } from "@/hooks/use-toast"
|
||||
import { useHelpers } from "@/lib/client-helpers"
|
||||
|
||||
export function Profile() {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
|
||||
import { useHelpers } from '@/lib/client-helpers';
|
||||
import { Permission } from '@/lib/types';
|
||||
import { passwordSchema, usernameSchema } from '@/lib/zod';
|
||||
import { Input } from './ui/input';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import _ from 'lodash';
|
||||
import { User as UserIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { PermissionSelector } from './PermissionSelector';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Switch } from './ui/switch';
|
||||
import { Permission } from '@/lib/types';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
|
||||
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
|
||||
import { SafeUser, User } from '@/lib/types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { User as UserIcon } from 'lucide-react';
|
||||
import _ from 'lodash';
|
||||
import { PermissionSelector } from './PermissionSelector';
|
||||
import { useHelpers } from '@/lib/client-helpers';
|
||||
|
||||
interface UserFormProps {
|
||||
userId?: string; // if provided, we're editing; if not, we're creating
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { signIn } from '@/app/actions/user';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { usersAtom } from '@/lib/atoms';
|
||||
import { useHelpers } from '@/lib/client-helpers';
|
||||
import { SafeUser, User } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Description } from '@radix-ui/react-dialog';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Crown, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import PasswordEntryForm from './PasswordEntryForm';
|
||||
import UserForm from './UserForm';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { Crown, Pencil, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { useAtom } from 'jotai';
|
||||
import { usersAtom } from '@/lib/atoms';
|
||||
import { signIn } from '@/app/actions/user';
|
||||
import { createUser } from '@/app/actions/data';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Description } from '@radix-ui/react-dialog';
|
||||
import { SafeUser, User } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useHelpers } from '@/lib/client-helpers';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
function UserCard({
|
||||
user,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { CheckSquare, ListChecks } from 'lucide-react'
|
||||
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import type { ViewType } from '@/lib/types'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { isHabitDueToday } from '@/lib/utils'
|
||||
import type { ViewType } from '@/lib/types'
|
||||
import { cn, isHabitDueToday } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { NotificationBadge } from './ui/notification-badge'
|
||||
|
||||
interface ViewToggleProps {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { WishlistItemType, User, Permission } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { usersAtom } from '@/lib/atoms'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Coins, Edit, Trash2, Gift, MoreVertical, Archive, ArchiveRestore } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -14,6 +7,12 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { usersAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { User, WishlistItemType } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
|
||||
interface WishlistItemProps {
|
||||
item: WishlistItemType
|
||||
|
||||
@@ -88,9 +88,9 @@ export default function WishlistManager() {
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Reward
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
|
||||
{activeItems.length === 0 ? (
|
||||
<div className="col-span-2">
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<EmptyState
|
||||
icon={Gift}
|
||||
title="Your wishlist is empty"
|
||||
@@ -127,7 +127,7 @@ export default function WishlistManager() {
|
||||
|
||||
{archivedItems.length > 0 && (
|
||||
<>
|
||||
<div className="col-span-2 relative flex items-center my-6">
|
||||
<div className="col-span-1 lg:col-span-2 relative flex items-center my-6">
|
||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
|
||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Moon, MoonIcon, Sun } from "lucide-react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,14 +2,14 @@ import { useAtom } from 'jotai'
|
||||
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
|
||||
import {
|
||||
coinsAtom,
|
||||
// coinsEarnedTodayAtom,
|
||||
// totalEarnedAtom,
|
||||
// totalSpentAtom,
|
||||
// coinsSpentTodayAtom,
|
||||
// transactionsTodayAtom,
|
||||
// coinsBalanceAtom,
|
||||
coinsEarnedTodayAtom,
|
||||
totalEarnedAtom,
|
||||
totalSpentAtom,
|
||||
coinsSpentTodayAtom,
|
||||
transactionsTodayAtom,
|
||||
coinsBalanceAtom,
|
||||
settingsAtom,
|
||||
usersAtom
|
||||
usersAtom,
|
||||
} from '@/lib/atoms'
|
||||
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
|
||||
import { CoinsData, User } from '@/lib/types'
|
||||
@@ -29,7 +29,7 @@ function handlePermissionCheck(
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
||||
toast({
|
||||
title: "Permission Denied",
|
||||
@@ -38,7 +38,7 @@ function handlePermissionCheck(
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -57,12 +57,12 @@ export function useCoins(options?: { selectedUser?: string }) {
|
||||
// Filter transactions for the selectd user
|
||||
const transactions = coins.transactions.filter(t => t.userId === user?.id)
|
||||
|
||||
const balance = transactions.reduce((sum, t) => sum + t.amount, 0)
|
||||
const coinsEarnedToday = calculateCoinsEarnedToday(transactions, settings.system.timezone)
|
||||
const totalEarned = calculateTotalEarned(transactions)
|
||||
const totalSpent = calculateTotalSpent(transactions)
|
||||
const coinsSpentToday = calculateCoinsSpentToday(transactions, settings.system.timezone)
|
||||
const transactionsToday = calculateTransactionsToday(transactions, settings.system.timezone)
|
||||
const [balance] = useAtom(coinsBalanceAtom)
|
||||
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
|
||||
const [totalEarned] = useAtom(totalEarnedAtom)
|
||||
const [totalSpent] = useAtom(totalSpentAtom)
|
||||
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
|
||||
const [transactionsToday] = useAtom(transactionsTodayAtom)
|
||||
|
||||
const add = async (amount: number, description: string, note?: string) => {
|
||||
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { habitsAtom, coinsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { useAtom, atom } from 'jotai'
|
||||
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom } from '@/lib/atoms'
|
||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||
import { Habit, Permission, SafeUser, User } from '@/lib/types'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -34,7 +34,7 @@ function handlePermissionCheck(
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
||||
toast({
|
||||
title: "Permission Denied",
|
||||
@@ -43,7 +43,7 @@ function handlePermissionCheck(
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function useHabits() {
|
||||
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
||||
const [coins, setCoins] = useAtom(coinsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [habitFreqMap] = useAtom(habitFreqMapAtom)
|
||||
|
||||
const completeHabit = async (habit: Habit) => {
|
||||
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
|
||||
@@ -313,6 +314,7 @@ export function useHabits() {
|
||||
deleteHabit,
|
||||
completePastHabit,
|
||||
archiveHabit,
|
||||
unarchiveHabit
|
||||
unarchiveHabit,
|
||||
habitFreqMap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
// 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.`);
|
||||
}
|
||||
}
|
||||
|
||||
114
lib/atoms.ts
114
lib/atoms.ts
@@ -1,33 +1,30 @@
|
||||
import { atom } from "jotai";
|
||||
import {
|
||||
getDefaultSettings,
|
||||
getDefaultHabitsData,
|
||||
getDefaultCoinsData,
|
||||
getDefaultWishlistData,
|
||||
Habit,
|
||||
ViewType,
|
||||
getDefaultUsersData,
|
||||
CompletionCache,
|
||||
getDefaultServerSettings,
|
||||
User,
|
||||
} from "./types";
|
||||
import {
|
||||
getTodayInTimezone,
|
||||
isSameDate,
|
||||
t2d,
|
||||
calculateCoinsEarnedToday,
|
||||
calculateCoinsSpentToday,
|
||||
calculateTotalEarned,
|
||||
calculateTotalSpent,
|
||||
calculateCoinsSpentToday,
|
||||
calculateTransactionsToday,
|
||||
getCompletionsForToday,
|
||||
getISODate,
|
||||
isHabitDueToday,
|
||||
getNow,
|
||||
isHabitDue
|
||||
getHabitFreq,
|
||||
getTodayInTimezone,
|
||||
isHabitDue,
|
||||
t2d
|
||||
} from "@/lib/utils";
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
CompletionCache,
|
||||
Freq,
|
||||
getDefaultCoinsData,
|
||||
getDefaultHabitsData,
|
||||
getDefaultServerSettings,
|
||||
getDefaultSettings,
|
||||
getDefaultUsersData,
|
||||
getDefaultWishlistData,
|
||||
Habit,
|
||||
ViewType
|
||||
} from "./types";
|
||||
|
||||
export interface BrowserSettings {
|
||||
viewType: ViewType
|
||||
@@ -50,44 +47,44 @@ export const coinsAtom = atom(getDefaultCoinsData());
|
||||
export const wishlistAtom = atom(getDefaultWishlistData());
|
||||
export const serverSettingsAtom = atom(getDefaultServerSettings());
|
||||
|
||||
// // Derived atom for coins earned today
|
||||
// export const coinsEarnedTodayAtom = atom((get) => {
|
||||
// const coins = get(coinsAtom);
|
||||
// const settings = get(settingsAtom);
|
||||
// return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
||||
// });
|
||||
// Derived atom for coins earned today
|
||||
export const coinsEarnedTodayAtom = atom((get) => {
|
||||
const coins = get(coinsAtom);
|
||||
const settings = get(settingsAtom);
|
||||
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
||||
});
|
||||
|
||||
// // Derived atom for total earned
|
||||
// export const totalEarnedAtom = atom((get) => {
|
||||
// const coins = get(coinsAtom);
|
||||
// return calculateTotalEarned(coins.transactions);
|
||||
// });
|
||||
// Derived atom for total earned
|
||||
export const totalEarnedAtom = atom((get) => {
|
||||
const coins = get(coinsAtom);
|
||||
return calculateTotalEarned(coins.transactions);
|
||||
});
|
||||
|
||||
// // Derived atom for total spent
|
||||
// export const totalSpentAtom = atom((get) => {
|
||||
// const coins = get(coinsAtom);
|
||||
// return calculateTotalSpent(coins.transactions);
|
||||
// });
|
||||
// Derived atom for total spent
|
||||
export const totalSpentAtom = atom((get) => {
|
||||
const coins = get(coinsAtom);
|
||||
return calculateTotalSpent(coins.transactions);
|
||||
});
|
||||
|
||||
// // Derived atom for coins spent today
|
||||
// export const coinsSpentTodayAtom = atom((get) => {
|
||||
// const coins = get(coinsAtom);
|
||||
// const settings = get(settingsAtom);
|
||||
// return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
||||
// });
|
||||
// Derived atom for coins spent today
|
||||
export const coinsSpentTodayAtom = atom((get) => {
|
||||
const coins = get(coinsAtom);
|
||||
const settings = get(settingsAtom);
|
||||
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
||||
});
|
||||
|
||||
// // Derived atom for transactions today
|
||||
// export const transactionsTodayAtom = atom((get) => {
|
||||
// const coins = get(coinsAtom);
|
||||
// const settings = get(settingsAtom);
|
||||
// return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
||||
// });
|
||||
// Derived atom for transactions today
|
||||
export const transactionsTodayAtom = atom((get) => {
|
||||
const coins = get(coinsAtom);
|
||||
const settings = get(settingsAtom);
|
||||
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
||||
});
|
||||
|
||||
// // Derived atom for current balance from all transactions
|
||||
// export const coinsBalanceAtom = atom((get) => {
|
||||
// const coins = get(coinsAtom);
|
||||
// return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0);
|
||||
// });
|
||||
// Derived atom for current balance from all transactions
|
||||
export const coinsBalanceAtom = atom((get) => {
|
||||
const coins = get(coinsAtom);
|
||||
return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0);
|
||||
});
|
||||
|
||||
/* transient atoms */
|
||||
interface PomodoroAtom {
|
||||
@@ -150,6 +147,15 @@ export const completedHabitsMapAtom = atom((get) => {
|
||||
return map;
|
||||
});
|
||||
|
||||
// Derived atom for habit frequency map
|
||||
export const habitFreqMapAtom = atom((get) => {
|
||||
const habits = get(habitsAtom).habits;
|
||||
const map = new Map<string, Freq>();
|
||||
habits.forEach(habit => {
|
||||
map.set(habit.id, getHabitFreq(habit));
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
export const pomodoroTodayCompletionsAtom = atom((get) => {
|
||||
const pomo = get(pomodoroAtom)
|
||||
|
||||
143
lib/backup.ts
Normal file
143
lib/backup.ts
Normal file
@@ -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<void>((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;
|
||||
}
|
||||
}
|
||||
54
lib/scheduler.ts
Normal file
54
lib/scheduler.ts
Normal file
@@ -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');
|
||||
// });
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
440
package-lock.json
generated
440
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.11",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user