From a615a45c39943e459e234b26c6ee5419f9ccbec8 Mon Sep 17 00:00:00 2001 From: dohsimpson Date: Wed, 26 Feb 2025 18:51:13 -0500 Subject: [PATCH] fix demo bugs --- .gitignore | 3 +- CHANGELOG.md | 8 +++++ app/actions/data.ts | 9 ++++- app/layout.tsx | 8 +++-- components/AddEditHabitModal.tsx | 35 +++++++++++++++----- components/DailyOverview.tsx | 16 ++++----- components/HabitItem.tsx | 8 +++-- components/UserForm.tsx | 9 ++--- components/jotai-hydrate.tsx | 5 +-- lib/atoms.ts | 2 ++ lib/env.server.ts | 3 +- lib/types.ts | 9 +++++ lib/utils.test.ts | 14 +++----- lib/utils.ts | 56 +++++++++++++++++++++++++++----- package.json | 2 +- 15 files changed, 135 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index cedf811..014ab87 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ yarn-error.log* next-env.d.ts # customize -data/* +/data/* +/data.*/* Budfile certificates diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b1cd3..255175b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 ### Changed diff --git a/app/actions/data.ts b/app/actions/data.ts index 45dc2c2..898e9d0 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -19,7 +19,8 @@ import { getDefaultWishlistData, getDefaultHabitsData, getDefaultCoinsData, - Permission + Permission, + ServerSettings } from '@/lib/types' import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils'; import { verifyPassword } from "@/lib/server-helpers"; @@ -474,3 +475,9 @@ export async function deleteUser(userId: string): Promise { await saveUsersData(newData) } + +export async function loadServerSettings(): Promise { + return { + isDemo: !!process.env.NEXT_PUBLIC_DEMO, + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 43311b4..169bb47 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,7 +4,7 @@ import { DM_Sans } from 'next/font/google' import { JotaiProvider } from '@/components/jotai-providers' import { Suspense } from 'react' 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 { Toaster } from '@/components/ui/toaster' import { ThemeProvider } from "@/components/theme-provider" @@ -37,12 +37,13 @@ export default async function RootLayout({ }: { children: React.ReactNode }) { - const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers] = await Promise.all([ + const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers, initialServerSettings] = await Promise.all([ loadSettings(), loadHabitsData(), loadCoinsData(), loadWishlistData(), loadUsersData(), + loadServerSettings(), ]) return ( @@ -74,7 +75,8 @@ export default async function RootLayout({ habits: initialHabits, coins: initialCoins, wishlist: initialWishlist, - users: initialUsers + users: initialUsers, + serverSettings: initialServerSettings, }} > (origRuleText) - const now = getNow({ timezone: settings.system.timezone }) const { currentUser } = useHelpers() const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false) const [selectedUserIds, setSelectedUserIds] = useState((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 (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) => { e.preventDefault() await onSave({ @@ -60,8 +78,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad coinReward, targetCompletions: targetCompletions > 1 ? targetCompletions : undefined, completions: habit?.completions || [], - frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }), - isTask: isTask || undefined, + frequency: getFrequencyUpdate(), userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]) }) } @@ -276,13 +293,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad { - setSelectedUserIds(prev => + setSelectedUserIds(prev => prev.includes(user.id) ? prev.filter(id => id !== user.id) : [...prev, user.id] diff --git a/components/DailyOverview.tsx b/components/DailyOverview.tsx index 4005a40..d070f62 100644 --- a/components/DailyOverview.tsx +++ b/components/DailyOverview.tsx @@ -168,10 +168,10 @@ export default function DailyOverview({ ${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`} key={habit.id} > - + -
+
- + {habit.name} @@ -223,7 +223,7 @@ export default function DailyOverview({ - + {habit.targetCompletions && ( {completionsToday}/{target} @@ -373,10 +373,10 @@ export default function DailyOverview({ ${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`} key={habit.id} > - + -
+
- + {habit.name} @@ -428,7 +428,7 @@ export default function DailyOverview({ - + {habit.targetCompletions && ( {completionsToday}/{target} diff --git a/components/HabitItem.tsx b/components/HabitItem.tsx index 051dba5..67c194e 100644 --- a/components/HabitItem.tsx +++ b/components/HabitItem.tsx @@ -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, 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 { Button } from '@/components/ui/button' import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar } from 'lucide-react' @@ -14,7 +14,7 @@ import { } from '@/components/ui/dropdown-menu' import { useEffect, useState } from 'react' 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 { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' import { useHelpers } from '@/lib/client-helpers' @@ -104,7 +104,9 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { )} -

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 })}

+

+ When: {getFrequencyDisplayText(habit.frequency, isRecurRule, settings.system.timezone)} +

{habit.coinReward} coins per completion diff --git a/components/UserForm.tsx b/components/UserForm.tsx index 5ed1889..5f563a7 100644 --- a/components/UserForm.tsx +++ b/components/UserForm.tsx @@ -8,8 +8,8 @@ import { Label } from './ui/label'; import { Switch } from './ui/switch'; import { Permission } from '@/lib/types'; import { toast } from '@/hooks/use-toast'; -import { useAtom } from 'jotai'; -import { usersAtom } from '@/lib/atoms'; +import { useAtom, useAtomValue } from 'jotai'; +import { serverSettingsAtom, usersAtom } from '@/lib/atoms'; import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data'; import { SafeUser, User } from '@/lib/types'; import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; @@ -26,6 +26,7 @@ interface UserFormProps { export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) { const [users, setUsersData] = useAtom(usersAtom); + const serverSettings = useAtomValue(serverSettingsAtom) const user = userId ? users.users.find(u => u.id === userId) : undefined; const { currentUser } = useHelpers() const getDefaultPermissions = (): Permission[] => [{ @@ -46,7 +47,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) const [avatarPath, setAvatarPath] = useState(user?.avatarPath) const [username, setUsername] = useState(user?.username || ''); const [password, setPassword] = useState(''); - 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 [avatarFile, setAvatarFile] = useState(null); const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false); @@ -240,7 +241,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) className={error ? 'border-red-500' : ''} disabled={disablePassword} /> - {process.env.NEXT_PUBLIC_DEMO === 'true' && ( + {serverSettings.isDemo && (

Password is automatically disabled in demo instance

)}
diff --git a/components/jotai-hydrate.tsx b/components/jotai-hydrate.tsx index fea0f4a..64f8fe5 100644 --- a/components/jotai-hydrate.tsx +++ b/components/jotai-hydrate.tsx @@ -1,6 +1,6 @@ '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 { JotaiHydrateInitialValues } from "@/lib/types" @@ -13,7 +13,8 @@ export function JotaiHydrate({ [habitsAtom, initialValues.habits], [coinsAtom, initialValues.coins], [wishlistAtom, initialValues.wishlist], - [usersAtom, initialValues.users] + [usersAtom, initialValues.users], + [serverSettingsAtom, initialValues.serverSettings] ]) return children } diff --git a/lib/atoms.ts b/lib/atoms.ts index 6a65802..3f78d71 100644 --- a/lib/atoms.ts +++ b/lib/atoms.ts @@ -8,6 +8,7 @@ import { ViewType, getDefaultUsersData, CompletionCache, + getDefaultServerSettings, } from "./types"; import { getTodayInTimezone, @@ -46,6 +47,7 @@ export const settingsAtom = atom(getDefaultSettings()); export const habitsAtom = atom(getDefaultHabitsData()); export const coinsAtom = atom(getDefaultCoinsData()); export const wishlistAtom = atom(getDefaultWishlistData()); +export const serverSettingsAtom = atom(getDefaultServerSettings()); // Derived atom for coins earned today export const coinsEarnedTodayAtom = atom((get) => { diff --git a/lib/env.server.ts b/lib/env.server.ts index a597391..54ba4ce 100644 --- a/lib/env.server.ts +++ b/lib/env.server.ts @@ -24,10 +24,9 @@ export function init() { ) .join("\n ") - console.error( + throw new Error( `Missing environment variables:\n ${errorMessage}`, ) - process.exit(1) } } } \ No newline at end of file diff --git a/lib/types.ts b/lib/types.ts index 82f7ead..62c1a64 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -130,6 +130,10 @@ export const getDefaultSettings = (): Settings => ({ profile: {} }); +export const getDefaultServerSettings = (): ServerSettings => ({ + isDemo: false +}) + // Map of data types to their default values export const DATA_DEFAULTS = { wishlist: getDefaultWishlistData, @@ -178,4 +182,9 @@ export interface JotaiHydrateInitialValues { habits: HabitsData; wishlist: WishlistData; users: UserData; + serverSettings: ServerSettings; } + +export interface ServerSettings { + isDemo: boolean +} \ No newline at end of file diff --git a/lib/utils.test.ts b/lib/utils.test.ts index 66ff2c0..8199c1f 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -535,13 +535,8 @@ describe('isHabitDueToday', () => { test('should return false for invalid recurrence rule', () => { const habit = testHabit('INVALID_RRULE') - // Mock console.error to prevent test output pollution - const consoleSpy = spyOn(console, 'error').mockImplementation(() => { }) - - // Expect the function to throw an error - expect(() => isHabitDueToday({ habit, timezone: 'UTC' })).toThrow() - - consoleSpy.mockRestore() + const consoleSpy = spyOn(console, 'error').mockImplementation(() => {}) + expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false) }) }) @@ -653,8 +648,7 @@ describe('isHabitDue', () => { test('should return false for invalid recurrence rule', () => { const habit = testHabit('INVALID_RRULE') const date = DateTime.fromISO('2024-01-01T00:00:00Z') - const consoleSpy = spyOn(console, 'error').mockImplementation(() => { }) - expect(() => isHabitDue({ habit, timezone: 'UTC', date })).toThrow() - consoleSpy.mockRestore() + const consoleSpy = spyOn(console, 'error').mockImplementation(() => {}) + expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false) }) }) diff --git a/lib/utils.ts b/lib/utils.ts index bf05680..126da57 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -3,7 +3,7 @@ import { twMerge } from "tailwind-merge" import { DateTime, DateTimeFormatOptions } from "luxon" import { datetime, RRule } from 'rrule' import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types' -import { DUE_MAP, 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 _ from "lodash" import { v4 as uuidv4 } from 'uuid' @@ -191,20 +191,28 @@ export function getRRuleUTC(recurrenceRule: string) { export function parseNaturalLanguageRRule(ruleText: string) { ruleText = ruleText.trim() + let rrule: RRule 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) { ruleText = ruleText.trim() + let rrule: RRule 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) { @@ -222,6 +230,25 @@ export function parseNaturalLanguageDate({ text, timezone }: { text: string, tim 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({ habit, timezone, @@ -247,8 +274,13 @@ export function isHabitDue({ const endOfDay = date.setZone(timezone).endOf('day') 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.options.tzid = rrule.origOptions.tzid 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.MONTHLY: return 'monthly' 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) export const playSound = (soundPath: string = '/sounds/timer-end.wav') => { const audio = new Audio(soundPath) diff --git a/package.json b/package.json index e230586..404d9e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.2.2", + "version": "0.2.3", "private": true, "scripts": { "dev": "next dev --turbopack",