mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a615a45c39 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,6 +41,7 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# customize
|
# customize
|
||||||
data/*
|
/data/*
|
||||||
|
/data.*/*
|
||||||
Budfile
|
Budfile
|
||||||
certificates
|
certificates
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
56
lib/utils.ts
56
lib/utils.ts
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user