Compare commits

...

1 Commits

Author SHA1 Message Date
dohsimpson
a615a45c39 fix demo bugs 2025-02-26 18:51:13 -05:00
15 changed files with 135 additions and 52 deletions

3
.gitignore vendored
View File

@@ -41,6 +41,7 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
# customize # customize
data/* /data/*
/data.*/*
Budfile Budfile
certificates certificates

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## Version 0.2.3
### Fixed
* gracefully handle invalid rrule (#76)
* fix long habit name overflow in daily (#75)
* disable password in demo instance (#74)
## Version 0.2.2 ## Version 0.2.2
### Changed ### Changed

View File

@@ -19,7 +19,8 @@ import {
getDefaultWishlistData, getDefaultWishlistData,
getDefaultHabitsData, getDefaultHabitsData,
getDefaultCoinsData, getDefaultCoinsData,
Permission Permission,
ServerSettings
} from '@/lib/types' } from '@/lib/types'
import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils'; import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils';
import { verifyPassword } from "@/lib/server-helpers"; import { verifyPassword } from "@/lib/server-helpers";
@@ -474,3 +475,9 @@ export async function deleteUser(userId: string): Promise<void> {
await saveUsersData(newData) await saveUsersData(newData)
} }
export async function loadServerSettings(): Promise<ServerSettings> {
return {
isDemo: !!process.env.NEXT_PUBLIC_DEMO,
}
}

View File

@@ -4,7 +4,7 @@ import { DM_Sans } from 'next/font/google'
import { JotaiProvider } from '@/components/jotai-providers' import { JotaiProvider } from '@/components/jotai-providers'
import { Suspense } from 'react' import { Suspense } from 'react'
import { JotaiHydrate } from '@/components/jotai-hydrate' import { JotaiHydrate } from '@/components/jotai-hydrate'
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData } from './actions/data' import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
import Layout from '@/components/Layout' import Layout from '@/components/Layout'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { ThemeProvider } from "@/components/theme-provider" import { ThemeProvider } from "@/components/theme-provider"
@@ -37,12 +37,13 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers] = await Promise.all([ const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers, initialServerSettings] = await Promise.all([
loadSettings(), loadSettings(),
loadHabitsData(), loadHabitsData(),
loadCoinsData(), loadCoinsData(),
loadWishlistData(), loadWishlistData(),
loadUsersData(), loadUsersData(),
loadServerSettings(),
]) ])
return ( return (
@@ -74,7 +75,8 @@ export default async function RootLayout({
habits: initialHabits, habits: initialHabits,
coins: initialCoins, coins: initialCoins,
wishlist: initialWishlist, wishlist: initialWishlist,
users: initialUsers users: initialUsers,
serverSettings: initialServerSettings,
}} }}
> >
<ThemeProvider <ThemeProvider

View File

@@ -16,8 +16,8 @@ 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, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils' import { d2s, d2t, getFrequencyDisplayText, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES } 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'
import { import {
@@ -43,15 +43,33 @@ 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 = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE const origRuleText = getFrequencyDisplayText(habit?.frequency, isRecurRule, settings.system.timezone)
const [ruleText, setRuleText] = useState<string>(origRuleText) const [ruleText, setRuleText] = useState<string>(origRuleText)
const now = getNow({ timezone: settings.system.timezone })
const { currentUser } = useHelpers() const { currentUser } = useHelpers()
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false) const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
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() {
if (ruleText === origRuleText && habit?.frequency) {
return habit.frequency
}
if (isRecurRule) {
const parsedRule = parseNaturalLanguageRRule(ruleText)
return serializeRRule(parsedRule)
} else {
const parsedDate = parseNaturalLanguageDate({
text: ruleText,
timezone: settings.system.timezone
})
return d2t({
dateTime: parsedDate,
timezone: settings.system.timezone
})
}
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
await onSave({ await onSave({
@@ -60,8 +78,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
coinReward, coinReward,
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined, targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
completions: habit?.completions || [], completions: habit?.completions || [],
frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }), frequency: getFrequencyUpdate(),
isTask: isTask || undefined,
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]) userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
}) })
} }

View File

@@ -168,10 +168,10 @@ export default function DailyOverview({
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`} ${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id} key={habit.id}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2 flex-1 min-w-0">
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div className="flex-none"> <div className="flex-shrink-0">
<button <button
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -204,7 +204,7 @@ export default function DailyOverview({
</button> </button>
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<span className={isCompleted ? 'line-through' : ''}> <span className={cn(isCompleted ? 'line-through' : '', 'break-all')}>
<Linkify> <Linkify>
{habit.name} {habit.name}
</Linkify> </Linkify>
@@ -223,7 +223,7 @@ export default function DailyOverview({
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
</span> </span>
<span className="flex items-center gap-2 text-xs text-muted-foreground"> <span className="flex items-center gap-2 text-xs text-muted-foreground flex-shrink-0">
{habit.targetCompletions && ( {habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full"> <span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target} {completionsToday}/{target}
@@ -373,10 +373,10 @@ export default function DailyOverview({
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`} ${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id} key={habit.id}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2 flex-1 min-w-0">
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div className="flex-none"> <div className="flex-shrink-0">
<button <button
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -409,7 +409,7 @@ export default function DailyOverview({
</button> </button>
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<span className={isCompleted ? 'line-through' : ''}> <span className={cn(isCompleted ? 'line-through' : '', 'break-all')}>
<Linkify> <Linkify>
{habit.name} {habit.name}
</Linkify> </Linkify>
@@ -428,7 +428,7 @@ export default function DailyOverview({
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
</span> </span>
<span className="flex items-center gap-2 text-xs text-muted-foreground"> <span className="flex items-center gap-2 text-xs text-muted-foreground flex-shrink-0">
{habit.targetCompletions && ( {habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full"> <span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target} {completionsToday}/{target}

View File

@@ -1,7 +1,7 @@
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, parseNaturalLanguageRRule, parseRRule, d2s, getCompletionsForToday, isTaskOverdue } from '@/lib/utils' import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseRRule, d2s, getCompletionsForToday, isTaskOverdue, getFrequencyDisplayText } 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 } from 'lucide-react'
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants' import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers' import { useHelpers } from '@/lib/client-helpers'
@@ -104,7 +104,9 @@ 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'}`}>When: {isRecurRule ? parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText() : d2s({ dateTime: t2d({ timestamp: habit.frequency, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</p> <p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
When: {getFrequencyDisplayText(habit.frequency, isRecurRule, settings.system.timezone)}
</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'}`} />
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.coinReward} coins per completion</span> <span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.coinReward} coins per completion</span>

View File

@@ -8,8 +8,8 @@ import { Label } from './ui/label';
import { Switch } from './ui/switch'; import { Switch } from './ui/switch';
import { Permission } from '@/lib/types'; import { Permission } from '@/lib/types';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { useAtom } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { usersAtom } from '@/lib/atoms'; import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data'; import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
import { SafeUser, User } from '@/lib/types'; import { SafeUser, User } from '@/lib/types';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
@@ -26,6 +26,7 @@ interface UserFormProps {
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) { export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
const [users, setUsersData] = useAtom(usersAtom); const [users, setUsersData] = useAtom(usersAtom);
const serverSettings = useAtomValue(serverSettingsAtom)
const user = userId ? users.users.find(u => u.id === userId) : undefined; const user = userId ? users.users.find(u => u.id === userId) : undefined;
const { currentUser } = useHelpers() const { currentUser } = useHelpers()
const getDefaultPermissions = (): Permission[] => [{ const getDefaultPermissions = (): Permission[] => [{
@@ -46,7 +47,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
const [avatarPath, setAvatarPath] = useState(user?.avatarPath) const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
const [username, setUsername] = useState(user?.username || ''); const [username, setUsername] = useState(user?.username || '');
const [password, setPassword] = useState<string | undefined>(''); const [password, setPassword] = useState<string | undefined>('');
const [disablePassword, setDisablePassword] = useState(user?.password === '' || process.env.NEXT_PUBLIC_DEMO === 'true'); const [disablePassword, setDisablePassword] = useState(user?.password === '' || serverSettings.isDemo);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [avatarFile, setAvatarFile] = useState<File | null>(null); const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false); const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
@@ -240,7 +241,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
className={error ? 'border-red-500' : ''} className={error ? 'border-red-500' : ''}
disabled={disablePassword} disabled={disablePassword}
/> />
{process.env.NEXT_PUBLIC_DEMO === 'true' && ( {serverSettings.isDemo && (
<p className="text-sm text-red-500">Password is automatically disabled in demo instance</p> <p className="text-sm text-red-500">Password is automatically disabled in demo instance</p>
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom } from "@/lib/atoms" import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom, serverSettingsAtom } from "@/lib/atoms"
import { useHydrateAtoms } from "jotai/utils" import { useHydrateAtoms } from "jotai/utils"
import { JotaiHydrateInitialValues } from "@/lib/types" import { JotaiHydrateInitialValues } from "@/lib/types"
@@ -13,7 +13,8 @@ export function JotaiHydrate({
[habitsAtom, initialValues.habits], [habitsAtom, initialValues.habits],
[coinsAtom, initialValues.coins], [coinsAtom, initialValues.coins],
[wishlistAtom, initialValues.wishlist], [wishlistAtom, initialValues.wishlist],
[usersAtom, initialValues.users] [usersAtom, initialValues.users],
[serverSettingsAtom, initialValues.serverSettings]
]) ])
return children return children
} }

View File

@@ -8,6 +8,7 @@ import {
ViewType, ViewType,
getDefaultUsersData, getDefaultUsersData,
CompletionCache, CompletionCache,
getDefaultServerSettings,
} from "./types"; } from "./types";
import { import {
getTodayInTimezone, getTodayInTimezone,
@@ -46,6 +47,7 @@ export const settingsAtom = atom(getDefaultSettings());
export const habitsAtom = atom(getDefaultHabitsData()); export const habitsAtom = atom(getDefaultHabitsData());
export const coinsAtom = atom(getDefaultCoinsData()); export const coinsAtom = atom(getDefaultCoinsData());
export const wishlistAtom = atom(getDefaultWishlistData()); export const wishlistAtom = atom(getDefaultWishlistData());
export const serverSettingsAtom = atom(getDefaultServerSettings());
// Derived atom for coins earned today // Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => { export const coinsEarnedTodayAtom = atom((get) => {

View File

@@ -24,10 +24,9 @@ export function init() {
) )
.join("\n ") .join("\n ")
console.error( throw new Error(
`Missing environment variables:\n ${errorMessage}`, `Missing environment variables:\n ${errorMessage}`,
) )
process.exit(1)
} }
} }
} }

View File

@@ -130,6 +130,10 @@ export const getDefaultSettings = (): Settings => ({
profile: {} profile: {}
}); });
export const getDefaultServerSettings = (): ServerSettings => ({
isDemo: false
})
// Map of data types to their default values // Map of data types to their default values
export const DATA_DEFAULTS = { export const DATA_DEFAULTS = {
wishlist: getDefaultWishlistData, wishlist: getDefaultWishlistData,
@@ -178,4 +182,9 @@ export interface JotaiHydrateInitialValues {
habits: HabitsData; habits: HabitsData;
wishlist: WishlistData; wishlist: WishlistData;
users: UserData; users: UserData;
serverSettings: ServerSettings;
}
export interface ServerSettings {
isDemo: boolean
} }

View File

@@ -535,13 +535,8 @@ describe('isHabitDueToday', () => {
test('should return false for invalid recurrence rule', () => { test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE') const habit = testHabit('INVALID_RRULE')
// Mock console.error to prevent test output pollution const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { }) expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
// Expect the function to throw an error
expect(() => isHabitDueToday({ habit, timezone: 'UTC' })).toThrow()
consoleSpy.mockRestore()
}) })
}) })
@@ -653,8 +648,7 @@ describe('isHabitDue', () => {
test('should return false for invalid recurrence rule', () => { test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE') const habit = testHabit('INVALID_RRULE')
const date = DateTime.fromISO('2024-01-01T00:00:00Z') const date = DateTime.fromISO('2024-01-01T00:00:00Z')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { }) const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
expect(() => isHabitDue({ habit, timezone: 'UTC', date })).toThrow() expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
consoleSpy.mockRestore()
}) })
}) })

View File

@@ -3,7 +3,7 @@ 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 } from '@/lib/types'
import { DUE_MAP, RECURRENCE_RULE_MAP } from "./constants" import { DUE_MAP, INITIAL_DUE, INITIAL_RECURRENCE_RULE, 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'
@@ -191,20 +191,28 @@ export function getRRuleUTC(recurrenceRule: string) {
export function parseNaturalLanguageRRule(ruleText: string) { export function parseNaturalLanguageRRule(ruleText: string) {
ruleText = ruleText.trim() ruleText = ruleText.trim()
let rrule: RRule
if (RECURRENCE_RULE_MAP[ruleText]) { if (RECURRENCE_RULE_MAP[ruleText]) {
return RRule.fromString(RECURRENCE_RULE_MAP[ruleText]) rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
} else {
rrule = RRule.fromText(ruleText)
} }
return RRule.fromText(ruleText) if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
return rrule
} }
export function parseRRule(ruleText: string) { export function parseRRule(ruleText: string) {
ruleText = ruleText.trim() ruleText = ruleText.trim()
let rrule: RRule
if (RECURRENCE_RULE_MAP[ruleText]) { if (RECURRENCE_RULE_MAP[ruleText]) {
return RRule.fromString(RECURRENCE_RULE_MAP[ruleText]) rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
} else {
rrule = RRule.fromString(ruleText)
} }
return RRule.fromString(ruleText) if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
return rrule
} }
export function serializeRRule(rrule: RRule) { export function serializeRRule(rrule: RRule) {
@@ -222,6 +230,25 @@ export function parseNaturalLanguageDate({ text, timezone }: { text: string, tim
return DateTime.fromJSDate(due).setZone(timezone) return DateTime.fromJSDate(due).setZone(timezone)
} }
export function getFrequencyDisplayText(frequency: string | undefined, isRecurRule: boolean, timezone: string) {
if (isRecurRule) {
try {
return parseRRule((frequency) || INITIAL_RECURRENCE_RULE).toText();
} catch {
return 'invalid'
}
} else {
if (!frequency) {
return INITIAL_DUE
}
return d2s({
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
}
}
export function isHabitDue({ export function isHabitDue({
habit, habit,
timezone, timezone,
@@ -247,8 +274,13 @@ 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
const rrule = parseRRule(ruleText) let rrule
try {
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)
@@ -296,10 +328,18 @@ export function getHabitFreq(habit: Habit): Freq {
case RRule.WEEKLY: return 'weekly' case RRule.WEEKLY: return 'weekly'
case RRule.MONTHLY: return 'monthly' case RRule.MONTHLY: return 'monthly'
case RRule.YEARLY: return 'yearly' case RRule.YEARLY: return 'yearly'
default: throw new Error(`Invalid frequency: ${freq}`)
default:
console.error(`Invalid frequency: ${freq} (habit: ${habit.id} ${habit.name}) (rrule: ${rrule.toString()}). Defaulting to daily`)
return 'daily'
} }
} }
export function isUnsupportedRRule(rrule: RRule): boolean {
const freq = rrule.origOptions.freq
return freq === RRule.HOURLY || freq === RRule.MINUTELY || freq === RRule.SECONDLY
}
// 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)

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.2", "version": "0.2.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",