Compare commits

...

5 Commits

Author SHA1 Message Date
dohsimpson
e53e2f649a fix build 2025-04-10 17:03:07 -04:00
dohsimpson
a42c0324c5 fix build 2025-04-10 16:56:11 -04:00
Doh
685cb80321 add support for habit pinning (#105) 2025-04-10 16:47:59 -04:00
Doh
f1e3ee5747 support interval habit frequency (#104) 2025-04-10 15:33:33 -04:00
dohsimpson
d31982bf29 [SECURITY] patched cve-2025-29927 2025-03-25 10:47:03 -04:00
15 changed files with 967 additions and 588 deletions

View File

@@ -52,13 +52,12 @@ jobs:
push: true
tags: |
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
${{ steps.check-version.outputs.EXISTS == 'false' && 'dohsimpson/habittrove:latest' || '' }}
dohsimpson/habittrove:dev
dohsimpson/habittrove:demo
deploy-demo:
runs-on: ubuntu-latest
needs: build-and-push
# demo tracks the latest tag
# demo tracks the demo tag
if: needs.build-and-push.outputs.EXISTS == 'false'
steps:
- uses: actions/checkout@v4

View File

@@ -21,5 +21,8 @@ jobs:
- name: Install dependencies
run: bun install
- name: Run lint
run: bun run lint
- name: Run unit tests
run: bun test

View File

@@ -1,5 +1,31 @@
# Changelog
## Version 0.2.7
### Added
* visual pin indicators for pinned habits/tasks
* pin/unpin options in context menus
* support click and right-click context menu in dailyoverview
## Version 0.2.6
### Added
* support weekly / monthly intervals for recurring frequency (#99)
* show error when frequency is unsupported (#56)
* add task / habit button in habit view
### Fixed
* make user select modal scrollable
## Version 0.2.5
### Changed
* bumped Nextjs version (cve-2025-29927)
## Version 0.2.4
### Added

View File

@@ -1,9 +1,9 @@
# syntax=docker.io/docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:18-alpine AS base
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM --platform=$BUILDPLATFORM base AS deps
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
@@ -19,7 +19,7 @@ RUN \
fi
# Rebuild the source code only when needed
FROM --platform=$BUILDPLATFORM base AS builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

View File

@@ -16,7 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { Habit, SafeUser } from '@/lib/types'
import { d2s, d2t, getFrequencyDisplayText, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
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 { DateTime } from 'luxon'
@@ -43,30 +43,41 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
const isRecurRule = !isTask
const origRuleText = getFrequencyDisplayText(habit?.frequency, isRecurRule, settings.system.timezone)
const [ruleText, setRuleText] = useState<string>(origRuleText)
// Initialize ruleText with the actual frequency string or default, not the display text
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule,
timezone: settings.system.timezone
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
const [ruleText, setRuleText] = useState<string>(initialRuleText)
const { currentUser } = useHelpers()
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [ruleError, setRuleError] = useState<string | null>(null); // State for validation message
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom)
const users = usersData.users
function getFrequencyUpdate() {
if (ruleText === origRuleText && habit?.frequency) {
return habit.frequency
if (ruleText === initialRuleText && habit?.frequency) {
// If text hasn't changed and original frequency exists, return it
return habit.frequency;
}
if (isRecurRule) {
const parsedRule = parseNaturalLanguageRRule(ruleText)
return serializeRRule(parsedRule)
const parsedResult = convertHumanReadableFrequencyToMachineReadable({
text: ruleText,
timezone: settings.system.timezone,
isRecurring: isRecurRule
});
if (parsedResult.result) {
return isRecurRule
? serializeRRule(parsedResult.result as RRule)
: d2t({
dateTime: parsedResult.result as DateTime,
timezone: settings.system.timezone
});
} else {
const parsedDate = parseNaturalLanguageDate({
text: ruleText,
timezone: settings.system.timezone
})
return d2t({
dateTime: parsedDate,
timezone: settings.system.timezone
})
return 'invalid';
}
}
@@ -146,6 +157,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
<Label htmlFor="recurrence" className="text-right">
When *
</Label>
{/* date input (task) */}
<div className="col-span-3 space-y-2">
<div className="flex gap-2">
<Input
@@ -189,16 +201,26 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
)}
</div>
</div>
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
<span>
{(() => {
try {
return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
} catch (e: unknown) {
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}`
}
})()}
</span>
{/* rrule input (habit) */}
<div className="col-start-2 col-span-3 text-sm">
{(() => {
let displayText = '';
let errorMessage: string | null = null;
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
errorMessage = message;
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
return (
<>
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{displayText}
</span>
{errorMessage && (
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
)}
</>
);
})()}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">

View File

@@ -1,4 +1,4 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus } from 'lucide-react'
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus, Pin, Calendar } from 'lucide-react'
import CompletionCountBadge from './CompletionCountBadge'
import {
ContextMenu,
@@ -6,16 +6,16 @@ import {
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
import { cn, getHabitFreq } from '@/lib/utils'
import Link from 'next/link'
import { useState, useEffect } from 'react'
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 { WishlistItemType } from '@/lib/types'
import { Settings, WishlistItemType } from '@/lib/types'
import { Habit } from '@/lib/types'
import Linkify from './linkify'
import { useHabits } from '@/hooks/useHabits'
@@ -28,6 +28,283 @@ interface UpcomingItemsProps {
coinBalance: number
}
interface ItemSectionProps {
title: string;
items: Habit[];
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 = ({
title,
items,
emptyMessage,
isTask,
viewLink,
expanded,
setExpanded,
addNewItem,
badgeType,
todayCompletions,
settings,
setBrowserSettings,
}: ItemSectionProps) => {
const { completeHabit, undoComplete, saveHabit } = useHabits();
const [_, setPomo] = useAtom(pomodoroAtom);
if (items.length === 0) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">{title}</h3>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={addNewItem}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add {isTask ? "Task" : "Habit"}</span>
</Button>
</div>
<div className="text-center text-muted-foreground text-sm py-4">
{emptyMessage}
</div>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{title}</h3>
</div>
<div className="flex items-center gap-2">
<CompletionCountBadge type={badgeType} />
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={addNewItem}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add {isTask ? "Task" : "Habit"}</span>
</Button>
</div>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expanded ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{items
.sort((a, b) => {
// First by pinned status
if (a.pinned !== b.pinned) {
return a.pinned ? -1 : 1;
}
// Then by completion status
const aCompleted = todayCompletions.includes(a);
const bCompleted = todayCompletions.includes(b);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then by frequency (daily first)
const aFreq = getHabitFreq(a);
const bFreq = getHabitFreq(b);
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
}
// Then by coin reward (higher first)
if (a.coinReward !== b.coinReward) {
return b.coinReward - a.coinReward;
}
// Finally by target completions (higher first)
const aTarget = a.targetCompletions || 1;
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, expanded ? 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 }))
).length
const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target || (isTask && habit.archived)
return (
<li
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id}
>
<span className="flex items-center gap-2 flex-1 min-w-0">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex items-center gap-2 cursor-pointer flex-1 min-w-0">
<div className="flex-shrink-0">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isCompleted) {
undoComplete(habit);
} else {
completeHabit(habit);
}
}}
className="relative hover:opacity-70 transition-opacity w-4 h-4"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completionsToday / target) * 360}deg,
transparent ${(completionsToday / target) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
<span className="flex items-center gap-1">
{habit.pinned && (
<Pin className="h-4 w-4 text-yellow-500" />
)}
<Link
href={`/habits?highlight=${habit.id}`}
className={cn(
isCompleted ? 'line-through' : '',
'break-all hover:text-primary transition-colors'
)}
>
{habit.name}
</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>
</ContextMenuContent>
</ContextMenu>
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground flex-shrink-0">
{habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target}
</span>
)}
{getHabitFreq(habit) !== 'daily' && (
<Badge variant="outline" className="text-xs">
{getHabitFreq(habit)}
</Badge>
)}
<span className="flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isCompleted
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isCompleted
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{habit.coinReward}
</span>
</span>
</span>
</li>
)
})}
</ul>
<div className="flex items-center justify-between">
<button
onClick={() => setExpanded(!expanded)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expanded ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
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' }));
}
}}
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
);
};
export default function DailyOverview({
habits,
wishlistItems,
@@ -37,12 +314,12 @@ export default function DailyOverview({
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 [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
// Filter out archived wishlist items
@@ -62,7 +339,7 @@ export default function DailyOverview({
})
const [hasTasks] = useAtom(hasTasksAtom)
const [_, setPomo] = useAtom(pomodoroAtom)
const [, setPomo] = useAtom(pomodoroAtom)
const [modalConfig, setModalConfig] = useState<{
isOpen: boolean,
isTask: boolean
@@ -80,414 +357,38 @@ export default function DailyOverview({
<CardContent>
<div className="space-y-6">
{/* Tasks Section */}
{hasTasks && dailyTasks.length === 0 ? (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Tasks</h3>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: true
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Task</span>
</Button>
</div>
<div className="text-center text-muted-foreground text-sm py-4">
No tasks due today. Add some tasks to get started!
</div>
</div>
) : hasTasks && (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold">Daily Tasks</h3>
</div>
<div className="flex items-center gap-2">
<CompletionCountBadge type="tasks" />
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: true
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Task</span>
</Button>
</div>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${browserSettings.expandedTasks ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{dailyTasks
.sort((a, b) => {
// First by completion status
const aCompleted = todayCompletions.includes(a);
const bCompleted = todayCompletions.includes(b);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then by frequency (daily first)
const aFreq = getHabitFreq(a);
const bFreq = getHabitFreq(b);
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
}
// Then by coin reward (higher first)
if (a.coinReward !== b.coinReward) {
return b.coinReward - a.coinReward;
}
// Finally by target completions (higher first)
const aTarget = a.targetCompletions || 1;
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, browserSettings.expandedTasks ? 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 }))
).length
const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target || (habit.isTask && habit.archived)
return (
<li
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id}
>
<span className="flex items-center gap-2 flex-1 min-w-0">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-shrink-0">
<button
onClick={(e) => {
e.preventDefault();
if (isCompleted) {
undoComplete(habit);
} else {
completeHabit(habit);
}
}}
className="relative hover:opacity-70 transition-opacity w-4 h-4"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completionsToday / target) * 360}deg,
transparent ${(completionsToday / target) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</ContextMenuTrigger>
<span className={cn(isCompleted ? 'line-through' : '', 'break-all')}>
<Linkify>
{habit.name}
</Linkify>
</span>
<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>
</ContextMenuContent>
</ContextMenu>
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground flex-shrink-0">
{habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target}
</span>
)}
{getHabitFreq(habit) !== 'daily' && (
<Badge variant="outline" className="text-xs">
{getHabitFreq(habit)}
</Badge>
)}
<span className="flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isCompleted
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isCompleted
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{habit.coinReward}
</span>
</span>
</span>
</li>
)
})}
</ul>
<div className="flex items-center justify-between">
<button
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedTasks: !prev.expandedTasks }))}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{browserSettings.expandedTasks ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/habits?view=tasks"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
onClick={() => setBrowserSettings(prev => ({ ...prev, viewType: 'tasks' }))}
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
{hasTasks && (
<ItemSection
title="Daily Tasks"
items={dailyTasks}
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}
/>
)}
{/* Habits Section */}
{dailyHabits.length === 0 ? (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Habits</h3>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: false
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Habit</span>
</Button>
</div>
<div className="text-center text-muted-foreground text-sm py-4">
No habits due today. Add some habits to get started!
</div>
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold">Daily Habits</h3>
</div>
<div className="flex items-center gap-2">
<CompletionCountBadge type="habits" />
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: false
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Habit</span>
</Button>
</div>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${browserSettings.expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{dailyHabits
.sort((a, b) => {
// First by completion status
const aCompleted = todayCompletions.includes(a);
const bCompleted = todayCompletions.includes(b);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then by frequency (daily first)
const aFreq = getHabitFreq(a);
const bFreq = getHabitFreq(b);
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
}
// Then by coin reward (higher first)
if (a.coinReward !== b.coinReward) {
return b.coinReward - a.coinReward;
}
// Finally by target completions (higher first)
const aTarget = a.targetCompletions || 1;
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, browserSettings.expandedHabits ? 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 }))
).length
const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target
return (
<li
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id}
>
<span className="flex items-center gap-2 flex-1 min-w-0">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-shrink-0">
<button
onClick={(e) => {
e.preventDefault();
if (isCompleted) {
undoComplete(habit);
} else {
completeHabit(habit);
}
}}
className="relative hover:opacity-70 transition-opacity w-4 h-4"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completionsToday / target) * 360}deg,
transparent ${(completionsToday / target) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</ContextMenuTrigger>
<span className={cn(isCompleted ? 'line-through' : '', 'break-all')}>
<Linkify>
{habit.name}
</Linkify>
</span>
<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>
</ContextMenuContent>
</ContextMenu>
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground flex-shrink-0">
{habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target}
</span>
)}
{getHabitFreq(habit) !== 'daily' && (
<Badge variant="outline" className="text-xs">
{getHabitFreq(habit)}
</Badge>
)}
<span className="flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isCompleted
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isCompleted
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{habit.coinReward}
</span>
</span>
</span>
</li>
)
})}
</ul>
<div className="flex items-center justify-between">
<button
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedHabits: !prev.expandedHabits }))}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{browserSettings.expandedHabits ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/habits"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
onClick={() => setBrowserSettings(prev => ({ ...prev, viewType: 'habits' }))}
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
)}
<ItemSection
title="Daily Habits"
items={dailyHabits}
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">
<div className="flex items-center justify-between mb-2">

View File

@@ -1,10 +1,10 @@
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, parseRRule, d2s, getCompletionsForToday, isTaskOverdue, getFrequencyDisplayText } from '@/lib/utils'
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 } from 'lucide-react'
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar, Pin } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -88,7 +88,12 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
<CardHeader className="flex-none">
<div className="flex justify-between items-start">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${isTasksView ? 'w-full' : ''} justify-between`}>
<span>{habit.name}</span>
<div className="flex items-center gap-1">
{habit.pinned && (
<Pin className="h-4 w-4 text-yellow-500" />
)}
<span>{habit.name}</span>
</div>
{isTaskOverdue(habit, settings.system.timezone) && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 dark:bg-red-900/30 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-400 ring-1 ring-inset ring-red-600/10 dark:ring-red-500/20">
Overdue
@@ -105,7 +110,11 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</CardHeader>
<CardContent className="flex-1">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
When: {getFrequencyDisplayText(habit.frequency, isRecurRule, settings.system.timezone)}
When: {convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule,
timezone: settings.system.timezone
})}
</p>
<div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
@@ -208,6 +217,19 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
<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>

View File

@@ -22,7 +22,9 @@ export default function HabitList() {
const habits = habitsData.habits.filter(habit =>
isTasksView ? habit.isTask : !habit.isTask
)
const activeHabits = habits.filter(h => !h.archived)
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 [modalConfig, setModalConfig] = useState<{
@@ -41,17 +43,22 @@ export default function HabitList() {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">
{isTasksView ? 'My Tasks' : 'My Habits'}
</h1>
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
</Button>
</div>
<div className='py-4'>
<ViewToggle />
</div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">
{isTasksView ? 'My Tasks' : 'My Habits'}
</h1>
<span>
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
<Plus className="mr-2 h-4 w-4" /> {'Add Task'}
</Button>
<Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
<Plus className="mr-2 h-4 w-4" /> {'Add Habit'}
</Button>
</span>
</div>
<div className='py-4'>
<ViewToggle />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{activeHabits.length === 0 ? (
<div className="col-span-2">
@@ -62,19 +69,19 @@ export default function HabitList() {
/>
</div>
) : (
activeHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))
)}
activeHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))
)}
{archivedHabits.length > 0 && (
<>
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">

View File

@@ -99,7 +99,7 @@ function UserSelectionView({
onCreateUser: () => void
}) {
return (
<div className="grid grid-cols-3 gap-4 p-2">
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
{users
.filter(user => user.id !== currentUser?.id)
.map((user) => (

View File

@@ -1,6 +1,6 @@
import { CheckSquare, Target } from "lucide-react"
export const INITIAL_RECURRENCE_RULE = 'daily'
export const INITIAL_RECURRENCE_RULE = 'every day'
export const INITIAL_DUE = 'today'
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {

View File

@@ -1,4 +1,6 @@
import { RRule } from "rrule"
import { uuid } from "./utils"
import { DateTime } from "luxon"
export type UserId = string
@@ -42,6 +44,7 @@ export type Habit = {
completions: string[] // Array of UTC ISO date strings
isTask?: boolean // mark the habit as a task
archived?: boolean // mark the habit as archived
pinned?: boolean // mark the habit as pinned
userIds?: UserId[]
}
@@ -187,4 +190,12 @@ export interface JotaiHydrateInitialValues {
export interface ServerSettings {
isDemo: boolean
}
}
export type ParsedResultType = DateTime<true> | RRule | string | null // null if invalid
// return rrule / datetime (machine-readable frequency), string (human-readable frequency), or null (invalid)
export interface ParsedFrequencyResult {
message: string | null
result: ParsedResultType
}

View File

@@ -1,4 +1,4 @@
import { expect, test, describe, beforeAll, beforeEach, afterAll, spyOn } from "bun:test";
import { expect, test, describe, beforeEach, spyOn } from "bun:test";
import {
cn,
getTodayInTimezone,
@@ -17,12 +17,18 @@ import {
isHabitDueToday,
isHabitDue,
uuid,
isTaskOverdue
isTaskOverdue,
deserializeRRule,
serializeRRule,
convertHumanReadableFrequencyToMachineReadable,
convertMachineReadableFrequencyToHumanReadable,
getUnsupportedRRuleReason
} from './utils'
import { CoinTransaction } from './types'
import { CoinTransaction, ParsedResultType } from './types'
import { DateTime } from "luxon";
import { RRule } from 'rrule';
import { RRule, Weekday } from 'rrule';
import { Habit } from '@/lib/types';
import { INITIAL_DUE } from './constants';
describe('cn utility', () => {
test('should merge class names correctly', () => {
@@ -33,6 +39,59 @@ describe('cn utility', () => {
})
})
describe('getUnsupportedRRuleReason', () => {
test('should return message for HOURLY frequency', () => {
const rrule = new RRule({ freq: RRule.HOURLY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Hourly frequency is not supported.');
});
test('should return message for MINUTELY frequency', () => {
const rrule = new RRule({ freq: RRule.MINUTELY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Minutely frequency is not supported.');
});
test('should return message for SECONDLY frequency', () => {
const rrule = new RRule({ freq: RRule.SECONDLY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Secondly frequency is not supported.');
});
test('should return message for DAILY frequency with interval > 1', () => {
const rrule = new RRule({ freq: RRule.DAILY, interval: 2 });
expect(getUnsupportedRRuleReason(rrule)).toBe('Daily frequency with intervals greater than 1 is not supported.');
});
test('should return null for DAILY frequency without interval', () => {
const rrule = new RRule({ freq: RRule.DAILY });
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for DAILY frequency with interval = 1', () => {
const rrule = new RRule({ freq: RRule.DAILY, interval: 1 });
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for WEEKLY frequency', () => {
const rrule = new RRule({ freq: RRule.WEEKLY, byweekday: [RRule.MO] }); // Added byweekday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for MONTHLY frequency', () => {
const rrule = new RRule({ freq: RRule.MONTHLY, bymonthday: [1] }); // Added bymonthday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for YEARLY frequency', () => {
const rrule = new RRule({ freq: RRule.YEARLY, bymonth: [1], bymonthday: [1] }); // Added bymonth/bymonthday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for WEEKLY frequency with interval', () => {
// Weekly with interval is supported
const rrule = new RRule({ freq: RRule.WEEKLY, interval: 2, byweekday: [RRule.TU] }); // Added byweekday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
});
describe('isTaskOverdue', () => {
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
id: 'test-habit',
@@ -652,3 +711,248 @@ describe('isHabitDue', () => {
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
})
})
describe('deserializeRRule', () => {
test('should deserialize valid RRule string', () => {
const rruleStr = 'FREQ=DAILY;INTERVAL=1'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeInstanceOf(RRule)
expect(rrule?.origOptions.freq).toBe(RRule.DAILY)
expect(rrule?.origOptions.interval).toBe(1)
})
test('should return null for invalid RRule string', () => {
const rruleStr = 'INVALID_RRULE_STRING'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeNull()
})
test('should handle complex RRule strings', () => {
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=10'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeInstanceOf(RRule)
expect(rrule?.origOptions.freq).toBe(RRule.WEEKLY)
expect(rrule?.origOptions.byweekday).toEqual([RRule.MO, RRule.WE, RRule.FR])
expect(rrule?.origOptions.interval).toBe(2)
expect(rrule?.origOptions.count).toBe(10)
})
})
describe('serializeRRule', () => {
test('should serialize RRule object to string', () => {
const rrule = new RRule({
freq: RRule.DAILY,
interval: 1
})
const rruleStr = serializeRRule(rrule)
// RRule adds DTSTART automatically if not provided, so we check the core parts
expect(rruleStr).toContain('FREQ=DAILY')
expect(rruleStr).toContain('INTERVAL=1')
})
test('should return "invalid" for null input', () => {
const rruleStr = serializeRRule(null)
expect(rruleStr).toBe('invalid')
})
test('should serialize complex RRule objects', () => {
const rrule = new RRule({
freq: RRule.WEEKLY,
byweekday: [RRule.MO, RRule.WE, RRule.FR],
interval: 2,
count: 10
})
const rruleStr = serializeRRule(rrule)
expect(rruleStr).toContain('FREQ=WEEKLY')
expect(rruleStr).toContain('BYDAY=MO,WE,FR')
expect(rruleStr).toContain('INTERVAL=2')
expect(rruleStr).toContain('COUNT=10')
})
})
describe('convertHumanReadableFrequencyToMachineReadable', () => {
const timezone = 'America/New_York'
beforeEach(() => {
// Set a fixed date for consistent relative date parsing
const mockDate = DateTime.fromISO('2024-07-15T10:00:00', { zone: timezone }) as DateTime<true>
DateTime.now = () => mockDate
})
// Non-recurring tests
test('should parse specific date (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'July 16, 2024', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
expect((result as DateTime).toISODate()).toBe('2024-07-16')
})
test('should parse relative date "tomorrow" (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'tomorrow', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
expect((result as DateTime).toISODate()).toBe('2024-07-16') // Based on mock date 2024-07-15
})
test('should parse relative date "next friday" (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'next friday', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
// chrono-node interprets "next friday" from Mon July 15 as Fri July 26
expect((result as DateTime).toISODate()).toBe('2024-07-26')
})
test('should return null for invalid date string (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid date', timezone, isRecurring: false })
expect(result).toBeNull()
expect(message).toBe('Invalid due date.')
})
// Recurring tests
test('should parse "daily" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'daily', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.DAILY)
})
test('should parse "every week on Monday" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week on Monday', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
// RRule.fromText returns Weekday objects, check the weekday property
const byweekday = (result as RRule).origOptions.byweekday;
const weekdayValues = byweekday
? (Array.isArray(byweekday)
? byweekday.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
: [typeof byweekday === 'number' ? byweekday : (byweekday as Weekday).weekday])
: [];
expect(weekdayValues).toEqual([RRule.MO.weekday])
})
test('should parse "every month on the 15th" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month on the 15th', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.MONTHLY)
expect((result as RRule).origOptions.bymonthday).toEqual([15])
})
test('should parse "every year on Jan 1" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every year on Jan 1', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.YEARLY)
// Note: RRule.fromText parses 'Jan 1' into bymonth/bymonthday
expect((result as RRule).origOptions.bymonth).toEqual([1])
// RRule.fromText might not reliably set bymonthday in origOptions for this text
// expect((result as RRule).origOptions.bymonthday).toEqual([1])
})
test('should return validation error for "every week" without day (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week', timezone, isRecurring: true })
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
expect(message).toBe('Please specify day(s) of the week (e.g., "every week on Mon, Wed").')
})
test('should return validation error for "every month" without day/position (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month', timezone, isRecurring: true })
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
expect(message).toBe('Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").')
})
test('should return null for invalid recurrence string (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid recurrence', timezone, isRecurring: true })
expect(result).toBeNull()
expect(message).toBe('Invalid recurrence rule.')
})
test('should return specific error for unsupported hourly frequency', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every hour', timezone, isRecurring: true })
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
expect(message).toBe('Hourly frequency is not supported.')
})
test('should return specific error for unsupported daily interval', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every 2 days', timezone, isRecurring: true })
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
expect(message).toBe('Daily frequency with intervals greater than 1 is not supported.')
})
test('should handle predefined constants like "weekdays"', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'weekdays', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
// Check the weekday property of the Weekday objects
const weekdays = (result as RRule).origOptions.byweekday;
const weekdayNumbers = weekdays
? (Array.isArray(weekdays)
? weekdays.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
: [typeof weekdays === 'number' ? weekdays : (weekdays as Weekday).weekday])
: [];
expect(weekdayNumbers).toEqual([RRule.MO.weekday, RRule.TU.weekday, RRule.WE.weekday, RRule.TH.weekday, RRule.FR.weekday])
})
})
describe('convertMachineReadableFrequencyToHumanReadable', () => {
const timezone = 'America/New_York'
// Non-recurring tests
test('should format DateTime object (non-recurring)', () => {
const dateTime = DateTime.fromISO('2024-07-16T00:00:00', { zone: timezone }) as DateTime<true>
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: dateTime, isRecurRule: false, timezone })
// Expected format depends on locale, check for key parts
expect(humanReadable).toContain('Jul 16, 2024')
expect(humanReadable).toContain('Tue') // Tuesday
})
test('should format ISO string (non-recurring)', () => {
const isoString = '2024-07-16T00:00:00.000-04:00' // Example ISO string with offset
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: isoString, isRecurRule: false, timezone })
expect(humanReadable).toContain('Jul 16, 2024')
expect(humanReadable).toContain('Tue')
})
test('should return "Initial Due" for null frequency (non-recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: false, timezone })
// Check against the imported constant value
expect(humanReadable).toBe(INITIAL_DUE)
})
// Recurring tests
test('should format RRule object (recurring)', () => {
const rrule = new RRule({ freq: RRule.DAILY })
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rrule, isRecurRule: true, timezone })
// rrule.toText() returns "every day" for daily rules
expect(humanReadable).toBe('every day')
})
test('should format RRule string (recurring)', () => {
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
expect(humanReadable).toBe('every week on Monday, Wednesday, Friday')
})
test('should return "invalid" for invalid RRule string (recurring)', () => {
const rruleStr = 'INVALID_RRULE'
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for null frequency (recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for unexpected type (recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: 123 as unknown as ParsedResultType, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for unexpected type (non-recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: new RRule({ freq: RRule.DAILY }) as unknown as ParsedResultType, isRecurRule: false, timezone })
expect(humanReadable).toBe('invalid')
})
})

View File

@@ -2,8 +2,8 @@ import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { DateTime, DateTimeFormatOptions } from "luxon"
import { datetime, RRule } from 'rrule'
import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types'
import { DUE_MAP, INITIAL_DUE, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants"
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType } from '@/lib/types'
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
import * as chrono from 'chrono-node'
import _ from "lodash"
import { v4 as uuidv4 } from 'uuid'
@@ -185,67 +185,125 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time
).length;
}
export function getRRuleUTC(recurrenceRule: string) {
return RRule.fromString(recurrenceRule); // this returns UTC
}
export function parseNaturalLanguageRRule(ruleText: string) {
ruleText = ruleText.trim()
let rrule: RRule
if (RECURRENCE_RULE_MAP[ruleText]) {
rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
} else {
rrule = RRule.fromText(ruleText)
// Enhanced validation for weekly/monthly rules
function validateRecurrenceRule(rrule: RRule | null): ParsedFrequencyResult {
if (!rrule) {
return { result: null, message: 'Invalid recurrence rule.' };
}
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
return rrule
}
export function parseRRule(ruleText: string) {
ruleText = ruleText.trim()
let rrule: RRule
if (RECURRENCE_RULE_MAP[ruleText]) {
rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
} else {
rrule = RRule.fromString(ruleText)
const unsupportedReason = getUnsupportedRRuleReason(rrule);
if (unsupportedReason) {
return { result: rrule, message: unsupportedReason };
}
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
return rrule
const options = rrule.origOptions;
if (options.freq === RRule.WEEKLY && (!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) {
return { result: null, message: 'Please specify day(s) of the week (e.g., "every week on Mon, Wed").' };
}
if (options.freq === RRule.MONTHLY &&
(!options.bymonthday || !Array.isArray(options.bymonthday) || options.bymonthday.length === 0) &&
(!options.bysetpos || !Array.isArray(options.bysetpos) || options.bysetpos.length === 0) && // Need to check bysetpos for rules like "last Friday"
(!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) { // Need byweekday with bysetpos
return { result: null, message: 'Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").' };
}
return { result: rrule, message: null };
}
export function serializeRRule(rrule: RRule) {
// Convert a human-readable frequency (recurring or non-recurring) into a machine-readable one
export function convertHumanReadableFrequencyToMachineReadable({ text, timezone, isRecurring = false }: { text: string, timezone: string, isRecurring?: boolean }): ParsedFrequencyResult {
text = text.trim()
if (!isRecurring) {
if (DUE_MAP[text]) {
text = DUE_MAP[text]
}
const now = getNow({ timezone })
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
if (!due) return { result: null, message: 'Invalid due date.' }
const result = due ? DateTime.fromJSDate(due).setZone(timezone) : null
return { message: null, result: result ? (result.isValid ? result : null) : null }
}
let rrule: RRule | null
if (RECURRENCE_RULE_MAP[text]) {
rrule = deserializeRRule(RECURRENCE_RULE_MAP[text])
} else if (text.toLowerCase() === 'weekdays') {
// Handle 'weekdays' specifically if not in the map
rrule = new RRule({
freq: RRule.WEEKLY,
byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR]
});
} else {
try {
rrule = RRule.fromText(text)
} catch (error) {
rrule = null
}
}
return validateRecurrenceRule(rrule);
}
// convert a machine-readable rrule **string** to an rrule object
export function deserializeRRule(rruleStr: string): RRule | null {
try {
return RRule.fromString(rruleStr);
} catch (error) {
return null;
}
}
// convert a machine-readable rrule **object** to an rrule string
export function serializeRRule(rrule: RRule | null): string {
if (!rrule) return 'invalid'; // Handle null case explicitly
return rrule.toString()
}
export function parseNaturalLanguageDate({ text, timezone }: { text: string, timezone: string }) {
if (DUE_MAP[text]) {
text = DUE_MAP[text]
}
const now = getNow({ timezone })
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
if (!due) throw Error('invalid rule')
// return d2s({ dateTime: DateTime.fromJSDate(due), timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
return DateTime.fromJSDate(due).setZone(timezone)
}
export function getFrequencyDisplayText(frequency: string | undefined, isRecurRule: boolean, timezone: string) {
// Convert a machine-readable frequency (recurring or non-recurring) into a human-readable one
export function convertMachineReadableFrequencyToHumanReadable({
frequency,
isRecurRule,
timezone
}: {
frequency: ParsedResultType,
isRecurRule: boolean,
timezone: string
}): string {
if (isRecurRule) {
try {
return parseRRule((frequency) || INITIAL_RECURRENCE_RULE).toText();
} catch {
return 'invalid'
if (!frequency) {
return 'invalid'; // Handle null/undefined for recurring rules
}
if (frequency instanceof RRule) {
return frequency.toText();
} else if (typeof frequency === "string") {
const parsedResult = deserializeRRule(frequency);
return parsedResult?.toText() || 'invalid';
} else {
return 'invalid';
}
} else {
// Handle non-recurring frequency
if (!frequency) {
return INITIAL_DUE
// Use the imported constant for initial due date text
return INITIAL_DUE;
}
if (typeof frequency === 'string') {
return d2s({
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
} else if (frequency instanceof DateTime) {
return d2s({
dateTime: frequency,
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
} else {
return 'invalid';
}
return d2s({
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
}
}
@@ -274,13 +332,8 @@ export function isHabitDue({
const endOfDay = date.setZone(timezone).endOf('day')
const ruleText = habit.frequency
let rrule
try {
rrule = parseRRule(ruleText)
} catch (error) {
console.error(`Failed to parse rrule for habit: ${habit.id} ${habit.name}`)
return false
}
const rrule = deserializeRRule(ruleText)
if (!rrule) return false
rrule.origOptions.tzid = timezone
rrule.options.tzid = rrule.origOptions.tzid
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
@@ -321,7 +374,7 @@ export function getHabitFreq(habit: Habit): Freq {
// don't support recurring task yet
return 'daily'
}
const rrule = parseRRule(habit.frequency)
const rrule = RRule.fromString(habit.frequency)
const freq = rrule.origOptions.freq
switch (freq) {
case RRule.DAILY: return 'daily'
@@ -335,11 +388,32 @@ export function getHabitFreq(habit: Habit): Freq {
}
}
export function isUnsupportedRRule(rrule: RRule): boolean {
const freq = rrule.origOptions.freq
return freq === RRule.HOURLY || freq === RRule.MINUTELY || freq === RRule.SECONDLY
/**
* Checks if an RRule is unsupported and returns the reason.
* @param rrule The RRule object to check.
* @returns A string message explaining why the rule is unsupported, or null if it's supported.
*/
export function getUnsupportedRRuleReason(rrule: RRule): string | null {
const freq = rrule.origOptions.freq;
const interval = rrule.origOptions.interval || 1; // RRule defaults interval to 1
if (freq === RRule.HOURLY) {
return 'Hourly frequency is not supported.';
}
if (freq === RRule.MINUTELY) {
return 'Minutely frequency is not supported.';
}
if (freq === RRule.SECONDLY) {
return 'Secondly frequency is not supported.';
}
if (freq === RRule.DAILY && interval > 1) {
return 'Daily frequency with intervals greater than 1 is not supported.';
}
return null; // Rule is supported
}
// play sound (client side only, must be run in browser)
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
const audio = new Audio(soundPath)
@@ -360,10 +434,10 @@ export const openWindow = (url: string): boolean => {
export function deepMerge<T>(a: T, b: T) {
return _.merge(a, b, (x: unknown, y: unknown) => {
if (_.isArray(a)) {
return a.concat(b)
}
})
if (_.isArray(a)) {
return a.concat(b)
}
})
}
export function checkPermission(
@@ -372,7 +446,7 @@ export function checkPermission(
action: 'write' | 'interact'
): boolean {
if (!permissions) return false
return permissions.some(permission => {
switch (resource) {
case 'habit':

94
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "habittrove",
"version": "0.2.0",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "habittrove",
"version": "0.2.0",
"version": "0.2.4",
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
@@ -36,7 +36,7 @@
"linkify-react": "^4.2.0",
"lucide-react": "^0.469.0",
"luxon": "^3.5.0",
"next": "15.1.3",
"next": "15.2.3",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.4",
"react": "^19.0.0",
@@ -774,9 +774,10 @@
}
},
"node_modules/@next/env": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.3.tgz",
"integrity": "sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw=="
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.3.tgz",
"integrity": "sha512-a26KnbW9DFEUsSxAxKBORR/uD9THoYoKbkpFywMN/AFvboTt94b8+g/07T8J6ACsdLag8/PDU60ov4rPxRAixw==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "15.1.3",
@@ -796,12 +797,13 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.3.tgz",
"integrity": "sha512-aZtmIh8jU89DZahXQt1La0f2EMPt/i7W+rG1sLtYJERsP7GRnNFghsciFpQcKHcGh4dUiyTB5C1X3Dde/Gw8gg==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.3.tgz",
"integrity": "sha512-uaBhA8aLbXLqwjnsHSkxs353WrRgQgiFjduDpc7YXEU0B54IKx3vU+cxQlYwPCyC8uYEEX7THhtQQsfHnvv8dw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -811,12 +813,13 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.3.tgz",
"integrity": "sha512-aw8901rjkVBK5mbq5oV32IqkJg+CQa6aULNlN8zyCWSsePzEG3kpDkAFkkTOh3eJ0p95KbkLyWBzslQKamXsLA==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.3.tgz",
"integrity": "sha512-pVwKvJ4Zk7h+4hwhqOUuMx7Ib02u3gDX3HXPKIShBi9JlYllI0nU6TWLbPT94dt7FSi6mSBhfc2JrHViwqbOdw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -826,12 +829,13 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.3.tgz",
"integrity": "sha512-YbdaYjyHa4fPK4GR4k2XgXV0p8vbU1SZh7vv6El4bl9N+ZSiMfbmqCuCuNU1Z4ebJMumafaz6UCC2zaJCsdzjw==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.3.tgz",
"integrity": "sha512-50ibWdn2RuFFkOEUmo9NCcQbbV9ViQOrUfG48zHBCONciHjaUKtHcYFiCwBVuzD08fzvzkWuuZkd4AqbvKO7UQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -841,12 +845,13 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.3.tgz",
"integrity": "sha512-qgH/aRj2xcr4BouwKG3XdqNu33SDadqbkqB6KaZZkozar857upxKakbRllpqZgWl/NDeSCBYPmUAZPBHZpbA0w==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.3.tgz",
"integrity": "sha512-2gAPA7P652D3HzR4cLyAuVYwYqjG0mt/3pHSWTCyKZq/N/dJcUAEoNQMyUmwTZWCJRKofB+JPuDVP2aD8w2J6Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -856,12 +861,13 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.3.tgz",
"integrity": "sha512-uzafnTFwZCPN499fNVnS2xFME8WLC9y7PLRs/yqz5lz1X/ySoxfaK2Hbz74zYUdEg+iDZPd8KlsWaw9HKkLEVw==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.3.tgz",
"integrity": "sha512-ODSKvrdMgAJOVU4qElflYy1KSZRM3M45JVbeZu42TINCMG3anp7YCBn80RkISV6bhzKwcUqLBAmOiWkaGtBA9w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -871,12 +877,13 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.3.tgz",
"integrity": "sha512-el6GUFi4SiDYnMTTlJJFMU+GHvw0UIFnffP1qhurrN1qJV3BqaSRUjkDUgVV44T6zpw1Lc6u+yn0puDKHs+Sbw==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.3.tgz",
"integrity": "sha512-ZR9kLwCWrlYxwEoytqPi1jhPd1TlsSJWAc+H/CJHmHkf2nD92MQpSRIURR1iNgA/kuFSdxB8xIPt4p/T78kwsg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -886,12 +893,13 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.3.tgz",
"integrity": "sha512-6RxKjvnvVMM89giYGI1qye9ODsBQpHSHVo8vqA8xGhmRPZHDQUE4jcDbhBwK0GnFMqBnu+XMg3nYukNkmLOLWw==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.3.tgz",
"integrity": "sha512-+G2FrDcfm2YDbhDiObDU/qPriWeiz/9cRR0yMWJeTLGGX6/x8oryO3tt7HhodA1vZ8r2ddJPCjtLcpaVl7TE2Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -901,12 +909,13 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.3.tgz",
"integrity": "sha512-VId/f5blObG7IodwC5Grf+aYP0O8Saz1/aeU3YcWqNdIUAmFQY3VEPKPaIzfv32F/clvanOb2K2BR5DtDs6XyQ==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.3.tgz",
"integrity": "sha512-gHYS9tc+G2W0ZC8rBL+H6RdtXIyk40uLiaos0yj5US85FNhbFEndMA2nW3z47nzOWiSvXTZ5kBClc3rD0zJg0w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -6513,11 +6522,12 @@
"peer": true
},
"node_modules/next": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/next/-/next-15.1.3.tgz",
"integrity": "sha512-5igmb8N8AEhWDYzogcJvtcRDU6n4cMGtBklxKD4biYv4LXN8+awc/bbQ2IM2NQHdVPgJ6XumYXfo3hBtErg1DA==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/next/-/next-15.2.3.tgz",
"integrity": "sha512-x6eDkZxk2rPpu46E1ZVUWIBhYCLszmUY6fvHBFcbzJ9dD+qRX6vcHusaqqDlnY+VngKzKbAiG2iRCkPbmi8f7w==",
"license": "MIT",
"dependencies": {
"@next/env": "15.1.3",
"@next/env": "15.2.3",
"@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15",
"busboy": "1.6.0",
@@ -6532,14 +6542,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.1.3",
"@next/swc-darwin-x64": "15.1.3",
"@next/swc-linux-arm64-gnu": "15.1.3",
"@next/swc-linux-arm64-musl": "15.1.3",
"@next/swc-linux-x64-gnu": "15.1.3",
"@next/swc-linux-x64-musl": "15.1.3",
"@next/swc-win32-arm64-msvc": "15.1.3",
"@next/swc-win32-x64-msvc": "15.1.3",
"@next/swc-darwin-arm64": "15.2.3",
"@next/swc-darwin-x64": "15.2.3",
"@next/swc-linux-arm64-gnu": "15.2.3",
"@next/swc-linux-arm64-musl": "15.2.3",
"@next/swc-linux-x64-gnu": "15.2.3",
"@next/swc-linux-x64-musl": "15.2.3",
"@next/swc-win32-arm64-msvc": "15.2.3",
"@next/swc-win32-x64-msvc": "15.2.3",
"sharp": "^0.33.5"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "habittrove",
"version": "0.2.4",
"version": "0.2.7",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -43,7 +43,7 @@
"linkify-react": "^4.2.0",
"lucide-react": "^0.469.0",
"luxon": "^3.5.0",
"next": "15.1.3",
"next": "15.2.3",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.4",
"react": "^19.0.0",