mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e53e2f649a | ||
|
|
a42c0324c5 | ||
|
|
685cb80321 | ||
|
|
f1e3ee5747 | ||
|
|
d31982bf29 |
5
.github/workflows/docker-publish.yml
vendored
5
.github/workflows/docker-publish.yml
vendored
@@ -52,13 +52,12 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
|
${{ 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:demo
|
||||||
dohsimpson/habittrove:dev
|
|
||||||
|
|
||||||
deploy-demo:
|
deploy-demo:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-and-push
|
needs: build-and-push
|
||||||
# demo tracks the latest tag
|
# demo tracks the demo tag
|
||||||
if: needs.build-and-push.outputs.EXISTS == 'false'
|
if: needs.build-and-push.outputs.EXISTS == 'false'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -21,5 +21,8 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
|
- name: Run lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: bun test
|
run: bun test
|
||||||
|
|||||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,5 +1,31 @@
|
|||||||
# Changelog
|
# 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
|
## Version 0.2.4
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker.io/docker/dockerfile:1
|
# 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
|
# 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.
|
# 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
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -19,7 +19,7 @@ RUN \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
FROM --platform=$BUILDPLATFORM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import data from '@emoji-mart/data'
|
import data from '@emoji-mart/data'
|
||||||
import Picker from '@emoji-mart/react'
|
import Picker from '@emoji-mart/react'
|
||||||
import { Habit, SafeUser } from '@/lib/types'
|
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 { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
||||||
import * as chrono from 'chrono-node';
|
import * as chrono from 'chrono-node';
|
||||||
import { DateTime } from 'luxon'
|
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 [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
||||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||||
const isRecurRule = !isTask
|
const isRecurRule = !isTask
|
||||||
const origRuleText = getFrequencyDisplayText(habit?.frequency, isRecurRule, settings.system.timezone)
|
// Initialize ruleText with the actual frequency string or default, not the display text
|
||||||
const [ruleText, setRuleText] = useState<string>(origRuleText)
|
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 { currentUser } = useHelpers()
|
||||||
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
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 [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const users = usersData.users
|
const users = usersData.users
|
||||||
|
|
||||||
function getFrequencyUpdate() {
|
function getFrequencyUpdate() {
|
||||||
if (ruleText === origRuleText && habit?.frequency) {
|
if (ruleText === initialRuleText && habit?.frequency) {
|
||||||
return habit.frequency
|
// If text hasn't changed and original frequency exists, return it
|
||||||
|
return habit.frequency;
|
||||||
}
|
}
|
||||||
if (isRecurRule) {
|
|
||||||
const parsedRule = parseNaturalLanguageRRule(ruleText)
|
const parsedResult = convertHumanReadableFrequencyToMachineReadable({
|
||||||
return serializeRRule(parsedRule)
|
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 {
|
} else {
|
||||||
const parsedDate = parseNaturalLanguageDate({
|
return 'invalid';
|
||||||
text: ruleText,
|
|
||||||
timezone: settings.system.timezone
|
|
||||||
})
|
|
||||||
return d2t({
|
|
||||||
dateTime: parsedDate,
|
|
||||||
timezone: settings.system.timezone
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +157,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
<Label htmlFor="recurrence" className="text-right">
|
<Label htmlFor="recurrence" className="text-right">
|
||||||
When *
|
When *
|
||||||
</Label>
|
</Label>
|
||||||
|
{/* date input (task) */}
|
||||||
<div className="col-span-3 space-y-2">
|
<div className="col-span-3 space-y-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -189,16 +201,26 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
|
{/* rrule input (habit) */}
|
||||||
<span>
|
<div className="col-start-2 col-span-3 text-sm">
|
||||||
{(() => {
|
{(() => {
|
||||||
try {
|
let displayText = '';
|
||||||
return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
|
let errorMessage: string | null = null;
|
||||||
} catch (e: unknown) {
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||||
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}`
|
errorMessage = message;
|
||||||
}
|
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
|
||||||
})()}
|
|
||||||
</span>
|
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>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
|||||||
@@ -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 CompletionCountBadge from './CompletionCountBadge'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -6,16 +6,16 @@ import {
|
|||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
|
import { cn, getHabitFreq } from '@/lib/utils'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom, dailyHabitsAtom } from '@/lib/atoms'
|
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom, dailyHabitsAtom } from '@/lib/atoms'
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
|
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { Settings, WishlistItemType } from '@/lib/types'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import Linkify from './linkify'
|
import Linkify from './linkify'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
@@ -28,6 +28,283 @@ interface UpcomingItemsProps {
|
|||||||
coinBalance: number
|
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({
|
export default function DailyOverview({
|
||||||
habits,
|
habits,
|
||||||
wishlistItems,
|
wishlistItems,
|
||||||
@@ -37,12 +314,12 @@ export default function DailyOverview({
|
|||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||||
const [dailyItems] = useAtom(dailyHabitsAtom)
|
const [dailyItems] = useAtom(dailyHabitsAtom)
|
||||||
|
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
||||||
const dailyTasks = dailyItems.filter(habit => habit.isTask)
|
const dailyTasks = dailyItems.filter(habit => habit.isTask)
|
||||||
const dailyHabits = dailyItems.filter(habit => !habit.isTask)
|
const dailyHabits = dailyItems.filter(habit => !habit.isTask)
|
||||||
const today = getTodayInTimezone(settings.system.timezone)
|
const today = getTodayInTimezone(settings.system.timezone)
|
||||||
const todayCompletions = completedHabitsMap.get(today) || []
|
const todayCompletions = completedHabitsMap.get(today) || []
|
||||||
const { saveHabit } = useHabits()
|
const { saveHabit } = useHabits()
|
||||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
|
||||||
|
|
||||||
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
|
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
|
||||||
// Filter out archived wishlist items
|
// Filter out archived wishlist items
|
||||||
@@ -62,7 +339,7 @@ export default function DailyOverview({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [hasTasks] = useAtom(hasTasksAtom)
|
const [hasTasks] = useAtom(hasTasksAtom)
|
||||||
const [_, setPomo] = useAtom(pomodoroAtom)
|
const [, setPomo] = useAtom(pomodoroAtom)
|
||||||
const [modalConfig, setModalConfig] = useState<{
|
const [modalConfig, setModalConfig] = useState<{
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
isTask: boolean
|
isTask: boolean
|
||||||
@@ -80,414 +357,38 @@ export default function DailyOverview({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Tasks Section */}
|
{/* Tasks Section */}
|
||||||
{hasTasks && dailyTasks.length === 0 ? (
|
{hasTasks && (
|
||||||
<div>
|
<ItemSection
|
||||||
<div className="flex items-center justify-between mb-2">
|
title="Daily Tasks"
|
||||||
<h3 className="font-semibold">Daily Tasks</h3>
|
items={dailyTasks}
|
||||||
<Button
|
emptyMessage="No tasks due today. Add some tasks to get started!"
|
||||||
variant="ghost"
|
isTask={true}
|
||||||
size="sm"
|
viewLink="/habits?view=tasks"
|
||||||
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
|
expanded={browserSettings.expandedTasks}
|
||||||
onClick={() => {
|
setExpanded={(value) => setBrowserSettings(prev => ({ ...prev, expandedTasks: value }))}
|
||||||
setModalConfig({
|
addNewItem={() => setModalConfig({ isOpen: true, isTask: true })}
|
||||||
isOpen: true,
|
badgeType="tasks"
|
||||||
isTask: true
|
todayCompletions={todayCompletions}
|
||||||
});
|
settings={settings}
|
||||||
}}
|
setBrowserSettings={setBrowserSettings}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Habits Section */}
|
{/* Habits Section */}
|
||||||
{dailyHabits.length === 0 ? (
|
<ItemSection
|
||||||
<div>
|
title="Daily Habits"
|
||||||
<div className="flex items-center justify-between mb-2">
|
items={dailyHabits}
|
||||||
<h3 className="font-semibold">Daily Habits</h3>
|
emptyMessage="No habits due today. Add some habits to get started!"
|
||||||
<Button
|
isTask={false}
|
||||||
variant="ghost"
|
viewLink="/habits"
|
||||||
size="sm"
|
expanded={browserSettings.expandedHabits}
|
||||||
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
|
setExpanded={(value) => setBrowserSettings(prev => ({ ...prev, expandedHabits: value }))}
|
||||||
onClick={() => {
|
addNewItem={() => setModalConfig({ isOpen: true, isTask: false })}
|
||||||
setModalConfig({
|
badgeType="habits"
|
||||||
isOpen: true,
|
todayCompletions={todayCompletions}
|
||||||
isTask: false
|
settings={settings}
|
||||||
});
|
setBrowserSettings={setBrowserSettings}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -88,7 +88,12 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
<CardHeader className="flex-none">
|
<CardHeader className="flex-none">
|
||||||
<div className="flex justify-between items-start">
|
<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`}>
|
<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) && (
|
{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">
|
<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
|
Overdue
|
||||||
@@ -105,7 +110,11 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1">
|
<CardContent className="flex-1">
|
||||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
<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>
|
</p>
|
||||||
<div className="flex items-center mt-2">
|
<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'}`} />
|
<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>
|
<span>Move to Today</span>
|
||||||
</DropdownMenuItem>
|
</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)}>
|
<DropdownMenuItem disabled={!canWrite} onClick={() => archiveHabit(habit.id)}>
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
<span>Archive</span>
|
<span>Archive</span>
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export default function HabitList() {
|
|||||||
const habits = habitsData.habits.filter(habit =>
|
const habits = habitsData.habits.filter(habit =>
|
||||||
isTasksView ? habit.isTask : !habit.isTask
|
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 archivedHabits = habits.filter(h => h.archived)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [modalConfig, setModalConfig] = useState<{
|
const [modalConfig, setModalConfig] = useState<{
|
||||||
@@ -41,17 +43,22 @@ export default function HabitList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-3xl font-bold">
|
||||||
{isTasksView ? 'My Tasks' : 'My Habits'}
|
{isTasksView ? 'My Tasks' : 'My Habits'}
|
||||||
</h1>
|
</h1>
|
||||||
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
|
<span>
|
||||||
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
|
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
|
||||||
</Button>
|
<Plus className="mr-2 h-4 w-4" /> {'Add Task'}
|
||||||
</div>
|
</Button>
|
||||||
<div className='py-4'>
|
<Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
|
||||||
<ViewToggle />
|
<Plus className="mr-2 h-4 w-4" /> {'Add Habit'}
|
||||||
</div>
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='py-4'>
|
||||||
|
<ViewToggle />
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||||
{activeHabits.length === 0 ? (
|
{activeHabits.length === 0 ? (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
@@ -62,19 +69,19 @@ export default function HabitList() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
activeHabits.map((habit: Habit) => (
|
activeHabits.map((habit: Habit) => (
|
||||||
<HabitItem
|
<HabitItem
|
||||||
key={habit.id}
|
key={habit.id}
|
||||||
habit={habit}
|
habit={habit}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingHabit(habit)
|
setEditingHabit(habit)
|
||||||
setModalConfig({ isOpen: true, isTask: isTasksView })
|
setModalConfig({ isOpen: true, isTask: isTasksView })
|
||||||
}}
|
}}
|
||||||
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{archivedHabits.length > 0 && (
|
{archivedHabits.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">
|
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function UserSelectionView({
|
|||||||
onCreateUser: () => void
|
onCreateUser: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
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
|
{users
|
||||||
.filter(user => user.id !== currentUser?.id)
|
.filter(user => user.id !== currentUser?.id)
|
||||||
.map((user) => (
|
.map((user) => (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CheckSquare, Target } from "lucide-react"
|
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 INITIAL_DUE = 'today'
|
||||||
|
|
||||||
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
|
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
|
||||||
|
|||||||
13
lib/types.ts
13
lib/types.ts
@@ -1,4 +1,6 @@
|
|||||||
|
import { RRule } from "rrule"
|
||||||
import { uuid } from "./utils"
|
import { uuid } from "./utils"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
export type UserId = string
|
export type UserId = string
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ export type Habit = {
|
|||||||
completions: string[] // Array of UTC ISO date strings
|
completions: string[] // Array of UTC ISO date strings
|
||||||
isTask?: boolean // mark the habit as a task
|
isTask?: boolean // mark the habit as a task
|
||||||
archived?: boolean // mark the habit as archived
|
archived?: boolean // mark the habit as archived
|
||||||
|
pinned?: boolean // mark the habit as pinned
|
||||||
userIds?: UserId[]
|
userIds?: UserId[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,4 +190,12 @@ export interface JotaiHydrateInitialValues {
|
|||||||
|
|
||||||
export interface ServerSettings {
|
export interface ServerSettings {
|
||||||
isDemo: boolean
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
cn,
|
cn,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
@@ -17,12 +17,18 @@ import {
|
|||||||
isHabitDueToday,
|
isHabitDueToday,
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
uuid,
|
uuid,
|
||||||
isTaskOverdue
|
isTaskOverdue,
|
||||||
|
deserializeRRule,
|
||||||
|
serializeRRule,
|
||||||
|
convertHumanReadableFrequencyToMachineReadable,
|
||||||
|
convertMachineReadableFrequencyToHumanReadable,
|
||||||
|
getUnsupportedRRuleReason
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { CoinTransaction } from './types'
|
import { CoinTransaction, ParsedResultType } from './types'
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { RRule } from 'rrule';
|
import { RRule, Weekday } from 'rrule';
|
||||||
import { Habit } from '@/lib/types';
|
import { Habit } from '@/lib/types';
|
||||||
|
import { INITIAL_DUE } from './constants';
|
||||||
|
|
||||||
describe('cn utility', () => {
|
describe('cn utility', () => {
|
||||||
test('should merge class names correctly', () => {
|
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', () => {
|
describe('isTaskOverdue', () => {
|
||||||
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
|
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
|
||||||
id: 'test-habit',
|
id: 'test-habit',
|
||||||
@@ -652,3 +711,248 @@ describe('isHabitDue', () => {
|
|||||||
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
204
lib/utils.ts
204
lib/utils.ts
@@ -2,8 +2,8 @@ import { clsx, type ClassValue } from "clsx"
|
|||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||||
import { datetime, RRule } from 'rrule'
|
import { datetime, RRule } from 'rrule'
|
||||||
import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types'
|
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType } from '@/lib/types'
|
||||||
import { DUE_MAP, INITIAL_DUE, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants"
|
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import _ from "lodash"
|
import _ from "lodash"
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
@@ -185,67 +185,125 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time
|
|||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRRuleUTC(recurrenceRule: string) {
|
// Enhanced validation for weekly/monthly rules
|
||||||
return RRule.fromString(recurrenceRule); // this returns UTC
|
function validateRecurrenceRule(rrule: RRule | null): ParsedFrequencyResult {
|
||||||
}
|
if (!rrule) {
|
||||||
|
return { result: null, message: 'Invalid recurrence rule.' };
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
|
const unsupportedReason = getUnsupportedRRuleReason(rrule);
|
||||||
return rrule
|
if (unsupportedReason) {
|
||||||
}
|
return { result: rrule, message: unsupportedReason };
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
|
const options = rrule.origOptions;
|
||||||
return rrule
|
|
||||||
|
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()
|
return rrule.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseNaturalLanguageDate({ text, timezone }: { text: string, timezone: string }) {
|
// Convert a machine-readable frequency (recurring or non-recurring) into a human-readable one
|
||||||
if (DUE_MAP[text]) {
|
export function convertMachineReadableFrequencyToHumanReadable({
|
||||||
text = DUE_MAP[text]
|
frequency,
|
||||||
}
|
isRecurRule,
|
||||||
const now = getNow({ timezone })
|
timezone
|
||||||
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
|
}: {
|
||||||
if (!due) throw Error('invalid rule')
|
frequency: ParsedResultType,
|
||||||
// return d2s({ dateTime: DateTime.fromJSDate(due), timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
|
isRecurRule: boolean,
|
||||||
return DateTime.fromJSDate(due).setZone(timezone)
|
timezone: string
|
||||||
}
|
}): string {
|
||||||
|
|
||||||
export function getFrequencyDisplayText(frequency: string | undefined, isRecurRule: boolean, timezone: string) {
|
|
||||||
if (isRecurRule) {
|
if (isRecurRule) {
|
||||||
try {
|
if (!frequency) {
|
||||||
return parseRRule((frequency) || INITIAL_RECURRENCE_RULE).toText();
|
return 'invalid'; // Handle null/undefined for recurring rules
|
||||||
} catch {
|
}
|
||||||
return 'invalid'
|
if (frequency instanceof RRule) {
|
||||||
|
return frequency.toText();
|
||||||
|
} else if (typeof frequency === "string") {
|
||||||
|
const parsedResult = deserializeRRule(frequency);
|
||||||
|
return parsedResult?.toText() || 'invalid';
|
||||||
|
} else {
|
||||||
|
return 'invalid';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Handle non-recurring frequency
|
||||||
if (!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 endOfDay = date.setZone(timezone).endOf('day')
|
||||||
|
|
||||||
const ruleText = habit.frequency
|
const ruleText = habit.frequency
|
||||||
let rrule
|
const rrule = deserializeRRule(ruleText)
|
||||||
try {
|
if (!rrule) return false
|
||||||
rrule = parseRRule(ruleText)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to parse rrule for habit: ${habit.id} ${habit.name}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
rrule.origOptions.tzid = timezone
|
rrule.origOptions.tzid = timezone
|
||||||
rrule.options.tzid = rrule.origOptions.tzid
|
rrule.options.tzid = rrule.origOptions.tzid
|
||||||
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
|
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
|
// don't support recurring task yet
|
||||||
return 'daily'
|
return 'daily'
|
||||||
}
|
}
|
||||||
const rrule = parseRRule(habit.frequency)
|
const rrule = RRule.fromString(habit.frequency)
|
||||||
const freq = rrule.origOptions.freq
|
const freq = rrule.origOptions.freq
|
||||||
switch (freq) {
|
switch (freq) {
|
||||||
case RRule.DAILY: return 'daily'
|
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
|
* Checks if an RRule is unsupported and returns the reason.
|
||||||
return freq === RRule.HOURLY || freq === RRule.MINUTELY || freq === RRule.SECONDLY
|
* @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)
|
// play sound (client side only, must be run in browser)
|
||||||
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
|
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
|
||||||
const audio = new Audio(soundPath)
|
const audio = new Audio(soundPath)
|
||||||
@@ -360,10 +434,10 @@ export const openWindow = (url: string): boolean => {
|
|||||||
|
|
||||||
export function deepMerge<T>(a: T, b: T) {
|
export function deepMerge<T>(a: T, b: T) {
|
||||||
return _.merge(a, b, (x: unknown, y: unknown) => {
|
return _.merge(a, b, (x: unknown, y: unknown) => {
|
||||||
if (_.isArray(a)) {
|
if (_.isArray(a)) {
|
||||||
return a.concat(b)
|
return a.concat(b)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkPermission(
|
export function checkPermission(
|
||||||
@@ -372,7 +446,7 @@ export function checkPermission(
|
|||||||
action: 'write' | 'interact'
|
action: 'write' | 'interact'
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!permissions) return false
|
if (!permissions) return false
|
||||||
|
|
||||||
return permissions.some(permission => {
|
return permissions.some(permission => {
|
||||||
switch (resource) {
|
switch (resource) {
|
||||||
case 'habit':
|
case 'habit':
|
||||||
|
|||||||
94
package-lock.json
generated
94
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.0",
|
"version": "0.2.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.0",
|
"version": "0.2.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"linkify-react": "^4.2.0",
|
"linkify-react": "^4.2.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "15.1.3",
|
"next": "15.2.3",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -774,9 +774,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.1.3",
|
"version": "15.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.3.tgz",
|
||||||
"integrity": "sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw=="
|
"integrity": "sha512-a26KnbW9DFEUsSxAxKBORR/uD9THoYoKbkpFywMN/AFvboTt94b8+g/07T8J6ACsdLag8/PDU60ov4rPxRAixw==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "15.1.3",
|
"version": "15.1.3",
|
||||||
@@ -796,12 +797,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.1.3",
|
"version": "15.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.3.tgz",
|
||||||
"integrity": "sha512-aZtmIh8jU89DZahXQt1La0f2EMPt/i7W+rG1sLtYJERsP7GRnNFghsciFpQcKHcGh4dUiyTB5C1X3Dde/Gw8gg==",
|
"integrity": "sha512-uaBhA8aLbXLqwjnsHSkxs353WrRgQgiFjduDpc7YXEU0B54IKx3vU+cxQlYwPCyC8uYEEX7THhtQQsfHnvv8dw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -811,12 +813,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.1.3",
|
"version": "15.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.3.tgz",
|
||||||
"integrity": "sha512-aw8901rjkVBK5mbq5oV32IqkJg+CQa6aULNlN8zyCWSsePzEG3kpDkAFkkTOh3eJ0p95KbkLyWBzslQKamXsLA==",
|
"integrity": "sha512-pVwKvJ4Zk7h+4hwhqOUuMx7Ib02u3gDX3HXPKIShBi9JlYllI0nU6TWLbPT94dt7FSi6mSBhfc2JrHViwqbOdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -826,12 +829,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.1.3",
|
"version": "15.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.3.tgz",
|
||||||
"integrity": "sha512-YbdaYjyHa4fPK4GR4k2XgXV0p8vbU1SZh7vv6El4bl9N+ZSiMfbmqCuCuNU1Z4ebJMumafaz6UCC2zaJCsdzjw==",
|
"integrity": "sha512-50ibWdn2RuFFkOEUmo9NCcQbbV9ViQOrUfG48zHBCONciHjaUKtHcYFiCwBVuzD08fzvzkWuuZkd4AqbvKO7UQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -841,12 +845,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.1.3",
|
"version": "15.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.3.tgz",
|
||||||
"integrity": "sha512-qgH/aRj2xcr4BouwKG3XdqNu33SDadqbkqB6KaZZkozar857upxKakbRllpqZgWl/NDeSCBYPmUAZPBHZpbA0w==",
|
"integrity": "sha512-2gAPA7P652D3HzR4cLyAuVYwYqjG0mt/3pHSWTCyKZq/N/dJcUAEoNQMyUmwTZWCJRKofB+JPuDVP2aD8w2J6Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -856,12 +861,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.1.3",
|
"version": "15.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.3.tgz",
|
||||||
"integrity": "sha512-uzafnTFwZCPN499fNVnS2xFME8WLC9y7PLRs/yqz5lz1X/ySoxfaK2Hbz74zYUdEg+iDZPd8KlsWaw9HKkLEVw==",
|
"integrity": "sha512-ODSKvrdMgAJOVU4qElflYy1KSZRM3M45JVbeZu42TINCMG3anp7YCBn80RkISV6bhzKwcUqLBAmOiWkaGtBA9w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -871,12 +877,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.1.3",
|
"version": "15.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.3.tgz",
|
||||||
"integrity": "sha512-el6GUFi4SiDYnMTTlJJFMU+GHvw0UIFnffP1qhurrN1qJV3BqaSRUjkDUgVV44T6zpw1Lc6u+yn0puDKHs+Sbw==",
|
"integrity": "sha512-ZR9kLwCWrlYxwEoytqPi1jhPd1TlsSJWAc+H/CJHmHkf2nD92MQpSRIURR1iNgA/kuFSdxB8xIPt4p/T78kwsg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -886,12 +893,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.1.3",
|
"version": "15.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.3.tgz",
|
||||||
"integrity": "sha512-6RxKjvnvVMM89giYGI1qye9ODsBQpHSHVo8vqA8xGhmRPZHDQUE4jcDbhBwK0GnFMqBnu+XMg3nYukNkmLOLWw==",
|
"integrity": "sha512-+G2FrDcfm2YDbhDiObDU/qPriWeiz/9cRR0yMWJeTLGGX6/x8oryO3tt7HhodA1vZ8r2ddJPCjtLcpaVl7TE2Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -901,12 +909,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.1.3",
|
"version": "15.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.3.tgz",
|
||||||
"integrity": "sha512-VId/f5blObG7IodwC5Grf+aYP0O8Saz1/aeU3YcWqNdIUAmFQY3VEPKPaIzfv32F/clvanOb2K2BR5DtDs6XyQ==",
|
"integrity": "sha512-gHYS9tc+G2W0ZC8rBL+H6RdtXIyk40uLiaos0yj5US85FNhbFEndMA2nW3z47nzOWiSvXTZ5kBClc3rD0zJg0w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -6513,11 +6522,12 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.1.3",
|
"version": "15.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.2.3.tgz",
|
||||||
"integrity": "sha512-5igmb8N8AEhWDYzogcJvtcRDU6n4cMGtBklxKD4biYv4LXN8+awc/bbQ2IM2NQHdVPgJ6XumYXfo3hBtErg1DA==",
|
"integrity": "sha512-x6eDkZxk2rPpu46E1ZVUWIBhYCLszmUY6fvHBFcbzJ9dD+qRX6vcHusaqqDlnY+VngKzKbAiG2iRCkPbmi8f7w==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.1.3",
|
"@next/env": "15.2.3",
|
||||||
"@swc/counter": "0.1.3",
|
"@swc/counter": "0.1.3",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"busboy": "1.6.0",
|
"busboy": "1.6.0",
|
||||||
@@ -6532,14 +6542,14 @@
|
|||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.1.3",
|
"@next/swc-darwin-arm64": "15.2.3",
|
||||||
"@next/swc-darwin-x64": "15.1.3",
|
"@next/swc-darwin-x64": "15.2.3",
|
||||||
"@next/swc-linux-arm64-gnu": "15.1.3",
|
"@next/swc-linux-arm64-gnu": "15.2.3",
|
||||||
"@next/swc-linux-arm64-musl": "15.1.3",
|
"@next/swc-linux-arm64-musl": "15.2.3",
|
||||||
"@next/swc-linux-x64-gnu": "15.1.3",
|
"@next/swc-linux-x64-gnu": "15.2.3",
|
||||||
"@next/swc-linux-x64-musl": "15.1.3",
|
"@next/swc-linux-x64-musl": "15.2.3",
|
||||||
"@next/swc-win32-arm64-msvc": "15.1.3",
|
"@next/swc-win32-arm64-msvc": "15.2.3",
|
||||||
"@next/swc-win32-x64-msvc": "15.1.3",
|
"@next/swc-win32-x64-msvc": "15.2.3",
|
||||||
"sharp": "^0.33.5"
|
"sharp": "^0.33.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.4",
|
"version": "0.2.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"linkify-react": "^4.2.0",
|
"linkify-react": "^4.2.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "15.1.3",
|
"next": "15.2.3",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user