Compare commits

...

5 Commits

Author SHA1 Message Date
ddffacbd52 fix: resolved navigator undefined error 2025-05-12 17:14:08 +02:00
Doh
95197e216c Update README.md 2025-05-10 19:55:03 -04:00
Doh
660005d857 Show overdue tasks and improved context menu (#110) 2025-05-10 15:51:39 -04:00
Doh
2408ed84bd performance optimization via atoms (#108) 2025-04-20 12:14:51 -04:00
Doh
dda8b522e3 Added auto-backups feature (#107) 2025-04-17 23:18:37 -04:00
24 changed files with 1362 additions and 270 deletions

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ next-env.d.ts
/data.*/*
Budfile
certificates
/backups/*

View File

@@ -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

View File

@@ -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"]

View File

@@ -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):

View File

@@ -64,6 +64,27 @@ async function ensureDataDir() {
}
}
// --- Backup Debug Action ---
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
// Optional: Add extra permission check if needed for debug actions
// const user = await getCurrentUser();
// if (!user?.isAdmin) {
// return { success: false, message: "Permission denied." };
// }
console.log("Manual backup trigger requested...");
try {
// Import runBackup locally to avoid potential circular dependencies if moved
const { runBackup } = await import('@/lib/backup');
await runBackup();
console.log("Manual backup trigger completed successfully.");
return { success: true, message: "Backup process completed successfully." };
} catch (error) {
console.error("Manual backup trigger failed:", error);
return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` };
}
}
async function loadData<T>(type: DataType): Promise<T> {
try {
await ensureDataDir()

60
app/debug/backup/page.tsx Normal file
View 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>
);
}

View File

@@ -1,19 +1,25 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
import { useAtom } from 'jotai';
import { settingsAtom } from '@/lib/atoms';
import { Settings, WeekDay } from '@/lib/types'
import { saveSettings, uploadAvatar } from '../actions/data'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { User } from 'lucide-react'
import { Button } from '@/components/ui/button';
import { User, Info } from 'lucide-react'; // Import Info icon
export default function SettingsPage() {
const [settings, setSettings] = useAtom(settingsAtom)
const [settings, setSettings] = useAtom(settingsAtom);
const updateSettings = async (newSettings: Settings) => {
await saveSettings(newSettings)
@@ -140,6 +146,46 @@ export default function SettingsPage() {
</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 >

View File

@@ -1,26 +1,35 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus, Pin, Calendar } from 'lucide-react'
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Plus, Pin, AlertTriangle } from 'lucide-react' // Removed unused icons
import CompletionCountBadge from './CompletionCountBadge'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { cn, getHabitFreq } from '@/lib/utils'
import { cn } from '@/lib/utils'
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 { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, isHabitDue, isTaskOverdue } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
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 ConfirmDialog from './ConfirmDialog'
import { Button } from './ui/button'
import { HabitContextMenuItems } from './HabitContextMenuItems'
interface UpcomingItemsProps {
habits: Habit[]
@@ -34,13 +43,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 +52,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 +122,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 +134,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 +143,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 +167,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 +223,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 +272,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 +300,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 +319,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 +329,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 +362,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 +424,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 +435,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">

View 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>
</>
);
}

View File

@@ -4,7 +4,7 @@ import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/li
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 { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react' // Removed unused icons
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,6 +18,7 @@ import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
import { DateTime } from 'luxon'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers'
import { HabitContextMenuItems } from './HabitContextMenuItems'
interface HabitItemProps {
habit: Habit
@@ -194,70 +195,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>

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Plus, ListTodo } from 'lucide-react'
import { useState, useMemo, useEffect } from 'react' // Added useMemo, useEffect
import { Plus, ArrowUpNarrowWide, ArrowDownWideNarrow, Search } from 'lucide-react' // Added sort icons, Search icon
import { useAtom } from 'jotai'
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import EmptyState from './EmptyState'
@@ -13,20 +13,100 @@ import { Habit } from '@/lib/types'
import { useHabits } from '@/hooks/useHabits'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { ViewToggle } from './ViewToggle'
import { Input } from '@/components/ui/input' // Added
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' // Added
import { Label } from '@/components/ui/label' // Added
import { DateTime } from 'luxon' // Added
import { getHabitFreq } from '@/lib/utils' // Added
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}

View File

@@ -2,10 +2,10 @@
import { Habit } from '@/lib/types'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { d2s, getNow, t2d, getCompletedHabitsForDate } from '@/lib/utils'
import { d2s, getNow, t2d } from '@/lib/utils' // Removed getCompletedHabitsForDate
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { useAtom } from 'jotai'
import { settingsAtom, hasTasksAtom } from '@/lib/atoms'
import { settingsAtom, hasTasksAtom, completedHabitsMapAtom } from '@/lib/atoms' // Added completedHabitsMapAtom
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
};
});

View File

@@ -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" />

View File

@@ -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

View File

@@ -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'
@@ -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

View File

@@ -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'
@@ -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,
}
}

View File

@@ -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.`);
}
}

View File

@@ -24,10 +24,12 @@ import {
getISODate,
isHabitDueToday,
getNow,
isHabitDue
isHabitDue,
getHabitFreq
} from "@/lib/utils";
import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon";
import { Freq } from "./types";
export interface BrowserSettings {
viewType: ViewType
@@ -50,44 +52,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 +152,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
View 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;
}
}

View File

@@ -1,9 +1,8 @@
// client helpers
'use-client'
import { useSession } from "next-auth/react"
import { User, UserId } from './types'
import { useAtom } from 'jotai'
import { useSession } from "next-auth/react"
import { usersAtom } from './atoms'
import { checkPermission } from './utils'
@@ -14,7 +13,7 @@ export function useHelpers() {
const currentUser = usersData.users.find((u) => u.id === currentUserId)
// detect iOS: https://stackoverflow.com/a/9039885
function iOS() {
return [
return typeof navigator !== "undefined" && ([
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
@@ -23,7 +22,7 @@ export function useHelpers() {
'iPod',
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document))
}
return {

54
lib/scheduler.ts Normal file
View 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');
// });
}

View File

@@ -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
View File

@@ -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",

View File

@@ -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",