mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
support interval habit frequency (#104)
This commit is contained in:
@@ -16,7 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import { Habit, SafeUser } from '@/lib/types'
|
||||
import { d2s, d2t, getFrequencyDisplayText, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
|
||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
|
||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
||||
import * as chrono from 'chrono-node';
|
||||
import { DateTime } from 'luxon'
|
||||
@@ -43,30 +43,41 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||
const isRecurRule = !isTask
|
||||
const origRuleText = getFrequencyDisplayText(habit?.frequency, isRecurRule, settings.system.timezone)
|
||||
const [ruleText, setRuleText] = useState<string>(origRuleText)
|
||||
// Initialize ruleText with the actual frequency string or default, not the display text
|
||||
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule,
|
||||
timezone: settings.system.timezone
|
||||
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
|
||||
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
||||
const { currentUser } = useHelpers()
|
||||
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
||||
const [ruleError, setRuleError] = useState<string | null>(null); // State for validation message
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const users = usersData.users
|
||||
|
||||
function getFrequencyUpdate() {
|
||||
if (ruleText === origRuleText && habit?.frequency) {
|
||||
return habit.frequency
|
||||
if (ruleText === initialRuleText && habit?.frequency) {
|
||||
// If text hasn't changed and original frequency exists, return it
|
||||
return habit.frequency;
|
||||
}
|
||||
if (isRecurRule) {
|
||||
const parsedRule = parseNaturalLanguageRRule(ruleText)
|
||||
return serializeRRule(parsedRule)
|
||||
|
||||
const parsedResult = convertHumanReadableFrequencyToMachineReadable({
|
||||
text: ruleText,
|
||||
timezone: settings.system.timezone,
|
||||
isRecurring: isRecurRule
|
||||
});
|
||||
|
||||
if (parsedResult.result) {
|
||||
return isRecurRule
|
||||
? serializeRRule(parsedResult.result as RRule)
|
||||
: d2t({
|
||||
dateTime: parsedResult.result as DateTime,
|
||||
timezone: settings.system.timezone
|
||||
});
|
||||
} else {
|
||||
const parsedDate = parseNaturalLanguageDate({
|
||||
text: ruleText,
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
return d2t({
|
||||
dateTime: parsedDate,
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
return 'invalid';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +157,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
<Label htmlFor="recurrence" className="text-right">
|
||||
When *
|
||||
</Label>
|
||||
{/* date input (task) */}
|
||||
<div className="col-span-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -189,16 +201,26 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{(() => {
|
||||
try {
|
||||
return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
|
||||
} catch (e: unknown) {
|
||||
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}`
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
{/* rrule input (habit) */}
|
||||
<div className="col-start-2 col-span-3 text-sm">
|
||||
{(() => {
|
||||
let displayText = '';
|
||||
let errorMessage: string | null = null;
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||
errorMessage = message;
|
||||
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{displayText}
|
||||
</span>
|
||||
{errorMessage && (
|
||||
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseRRule, d2s, getCompletionsForToday, isTaskOverdue, getFrequencyDisplayText } from '@/lib/utils'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar } from 'lucide-react'
|
||||
@@ -105,7 +105,11 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||
When: {getFrequencyDisplayText(habit.frequency, isRecurRule, settings.system.timezone)}
|
||||
When: {convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule,
|
||||
timezone: settings.system.timezone
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center mt-2">
|
||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
|
||||
@@ -41,17 +41,22 @@ export default function HabitList() {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isTasksView ? 'My Tasks' : 'My Habits'}
|
||||
</h1>
|
||||
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
|
||||
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='py-4'>
|
||||
<ViewToggle />
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isTasksView ? 'My Tasks' : 'My Habits'}
|
||||
</h1>
|
||||
<span>
|
||||
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
|
||||
<Plus className="mr-2 h-4 w-4" /> {'Add Task'}
|
||||
</Button>
|
||||
<Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
|
||||
<Plus className="mr-2 h-4 w-4" /> {'Add Habit'}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div className='py-4'>
|
||||
<ViewToggle />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||
{activeHabits.length === 0 ? (
|
||||
<div className="col-span-2">
|
||||
@@ -62,19 +67,19 @@ export default function HabitList() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
activeHabits.map((habit: Habit) => (
|
||||
<HabitItem
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
onEdit={() => {
|
||||
setEditingHabit(habit)
|
||||
setModalConfig({ isOpen: true, isTask: isTasksView })
|
||||
}}
|
||||
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
activeHabits.map((habit: Habit) => (
|
||||
<HabitItem
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
onEdit={() => {
|
||||
setEditingHabit(habit)
|
||||
setModalConfig({ isOpen: true, isTask: isTasksView })
|
||||
}}
|
||||
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{archivedHabits.length > 0 && (
|
||||
<>
|
||||
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">
|
||||
|
||||
@@ -99,7 +99,7 @@ function UserSelectionView({
|
||||
onCreateUser: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4 p-2">
|
||||
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
|
||||
{users
|
||||
.filter(user => user.id !== currentUser?.id)
|
||||
.map((user) => (
|
||||
|
||||
Reference in New Issue
Block a user