Compare commits

...

1 Commits

Author SHA1 Message Date
Doh
909bfa7c6f Added notification for admin user (#106) 2025-04-13 22:01:07 -04:00
14 changed files with 723 additions and 47 deletions

View File

@@ -1,7 +1 @@
if git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚'; then
echo "Error: Found debug marker 🪚 in these files:"
git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚' | awk -F: '{print " " $1 ":" $2}'
exit 1
fi
npm run typecheck && npm run test
npm run typecheck && npm run lint && npm run test

View File

@@ -1,5 +1,11 @@
# Changelog
## Version 0.2.8
### Added
* notification for admin users on shared habit / wishlist completion (#92)
## Version 0.2.7
### Added

View File

@@ -44,7 +44,7 @@ async function verifyPermission(
// if (!user) throw new PermissionError('User not authenticated')
// if (user.isAdmin) return // Admins bypass permission checks
// if (!checkPermission(user.permissions, resource, action)) {
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
// }
@@ -106,7 +106,7 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
export async function loadWishlistData(): Promise<WishlistData> {
const user = await getCurrentUser()
if (!user) return getDefaultWishlistData()
const data = await loadData<WishlistData>('wishlist')
return {
...data,
@@ -153,7 +153,7 @@ export async function loadHabitsData(): Promise<HabitsData> {
export async function saveHabitsData(data: HabitsData): Promise<void> {
await verifyPermission('habit', 'write')
const user = await getCurrentUser()
// Create clone of input data
const newData = _.cloneDeep(data)
@@ -229,6 +229,7 @@ export async function addCoins({
userId?: string
}): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const currentUser = await getCurrentUser()
const data = await loadCoinsData()
const newTransaction: CoinTransaction = {
id: uuid(),
@@ -238,7 +239,7 @@ export async function addCoins({
timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note }),
userId: userId || await getCurrentUserId()
userId: userId || currentUser?.id
}
const newData: CoinsData = {
@@ -283,6 +284,7 @@ export async function removeCoins({
userId?: string
}): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const currentUser = await getCurrentUser()
const data = await loadCoinsData()
const newTransaction: CoinTransaction = {
id: uuid(),
@@ -292,7 +294,7 @@ export async function removeCoins({
timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note }),
userId: userId || await getCurrentUserId()
userId: userId || currentUser?.id
}
const newData: CoinsData = {
@@ -368,8 +370,8 @@ export async function createUser(formData: FormData): Promise<User> {
const username = formData.get('username') as string;
let password = formData.get('password') as string | undefined;
const avatarPath = formData.get('avatarPath') as string;
const permissions = formData.get('permissions') ?
JSON.parse(formData.get('permissions') as string) as Permission[] :
const permissions = formData.get('permissions') ?
JSON.parse(formData.get('permissions') as string) as Permission[] :
undefined;
if (password === null) password = undefined
@@ -383,7 +385,7 @@ export async function createUser(formData: FormData): Promise<User> {
throw new Error('Username already exists');
}
const hashedPassword = password ? saltAndHashPassword(password) : '';
const hashedPassword = password ? saltAndHashPassword(password) : undefined;
const newUser: User = {
@@ -392,6 +394,7 @@ export async function createUser(formData: FormData): Promise<User> {
password: hashedPassword,
permissions,
isAdmin: false,
lastNotificationReadTimestamp: undefined,
...(avatarPath && { avatarPath })
};
@@ -482,6 +485,31 @@ export async function deleteUser(userId: string): Promise<void> {
await saveUsersData(newData)
}
export async function updateLastNotificationReadTimestamp(userId: string, timestamp: string): Promise<void> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found for updating notification timestamp')
}
const updatedUser = {
...data.users[userIndex],
lastNotificationReadTimestamp: timestamp
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
updatedUser,
...data.users.slice(userIndex + 1)
]
}
await saveUsersData(newData)
}
export async function loadServerSettings(): Promise<ServerSettings> {
return {
isDemo: !!process.env.DEMO,

0
app/actions/wishlist.ts Normal file
View File

View File

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react' // Import useEffect, useRef
import { useSearchParams } from 'next/navigation' // Import useSearchParams
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { FormattedNumber } from '@/components/FormattedNumber'
@@ -37,8 +38,32 @@ export default function CoinsManager() {
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
const [pageSize, setPageSize] = useState(50)
const [currentPage, setCurrentPage] = useState(1)
const [note, setNote] = useState('')
const searchParams = useSearchParams()
const highlightId = searchParams.get('highlight')
const userIdFromQuery = searchParams.get('user') // Get user ID from query
const transactionRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Effect to set selected user from query param if admin
useEffect(() => {
if (currentUser?.isAdmin && userIdFromQuery && userIdFromQuery !== selectedUser) {
// Check if the user ID from query exists in usersData
if (usersData.users.some(u => u.id === userIdFromQuery)) {
setSelectedUser(userIdFromQuery);
}
}
// Only run when userIdFromQuery or currentUser changes, avoid re-running on selectedUser change within this effect
}, [userIdFromQuery, currentUser, usersData.users]);
// Effect to scroll to highlighted transaction
useEffect(() => {
if (highlightId && transactionRefs.current[highlightId]) {
transactionRefs.current[highlightId]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [highlightId, transactions]); // Re-run if highlightId or transactions change
const handleSaveNote = async (transactionId: string, note: string) => {
await updateNote(transactionId, note)
@@ -249,13 +274,17 @@ export default function CoinsManager() {
}
}
const isHighlighted = transaction.id === highlightId;
return (
<div
key={transaction.id}
className="flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
ref={(el) => { transactionRefs.current[transaction.id] = el; }} // Assign ref correctly
className={`flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isHighlighted ? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/30' : '' // Apply highlight styles
}`}
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="space-y-1 flex-grow mr-4"> {/* Added flex-grow and margin */}
<div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
{transaction.relatedItemId ? (
<Link
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
@@ -274,12 +303,13 @@ export default function CoinsManager() {
</span>
{transaction.userId && currentUser?.isAdmin && (
<Avatar className="h-6 w-6">
<AvatarImage
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath &&
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` || ""}
<AvatarImage
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath ?
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` : undefined}
alt={usersData.users.find(u => u.id === transaction.userId)?.username}
/>
<AvatarFallback>
{usersData.users.find(u => u.id === transaction.userId)?.username[0]}
{usersData.users.find(u => u.id === transaction.userId)?.username?.[0] || '?'}
</AvatarFallback>
</Avatar>
)}
@@ -294,14 +324,16 @@ export default function CoinsManager() {
onDelete={handleDeleteNote}
/>
</div>
<span
className={`font-mono ${transaction.amount >= 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
</span>
<div className="flex-shrink-0 text-right"> {/* Ensure amount stays on the right */}
<span
className={`font-mono ${transaction.amount >= 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
</span>
</div>
</div>
)
})}

View File

@@ -1,13 +1,14 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Bell, Menu, Settings, User, Info, Coins } from 'lucide-react'
import { Menu, Settings, User, Info, Coins } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/Logo'
import NotificationBell from './NotificationBell'
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,6 +20,7 @@ import AboutModal from './AboutModal'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { Profile } from './Profile'
import { useHelpers } from '@/lib/client-helpers'
interface HeaderProps {
className?: string
@@ -52,9 +54,7 @@ export default function Header({ className }: HeaderProps) {
</div>
</div>
</Link>
<Button variant="ghost" size="icon" aria-label="Notifications">
<Bell className="h-5 w-5" />
</Button>
<NotificationBell />
<Profile />
</div>
</div>

View File

@@ -0,0 +1,133 @@
'use client'
import { useMemo } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom } from '@/lib/atoms'
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import NotificationDropdown from './NotificationDropdown';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
import { d2t, getNow, t2d } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers';
import { User, CoinTransaction } from '@/lib/types';
export default function NotificationBell() {
const { currentUser } = useHelpers();
const [coinsData] = useAtom(coinsAtom)
const [habitsData] = useAtom(habitsAtom)
const [wishlistData] = useAtom(wishlistAtom)
const [usersData] = useAtom(usersAtom);
// --- Calculate Unread and Read Notifications ---
const { unreadNotifications, displayedReadNotifications } = useMemo(() => {
const unread: CoinTransaction[] = [];
const read: CoinTransaction[] = [];
const MAX_READ_NOTIFICATIONS = 10; // Limit the number of past notifications shown
if (!currentUser || !currentUser.id) {
return { unreadNotifications: [], displayedReadNotifications: [] };
}
const lastReadTimestamp = currentUser.lastNotificationReadTimestamp
? t2d({ timestamp: currentUser.lastNotificationReadTimestamp, timezone: 'UTC' })
: null;
// Iterate through transactions (assuming they are sorted newest first)
for (const tx of coinsData.transactions) {
// Stop processing if we have enough read notifications
if (read.length >= MAX_READ_NOTIFICATIONS && (!lastReadTimestamp || t2d({ timestamp: tx.timestamp, timezone: 'UTC' }) <= lastReadTimestamp)) {
break; // Optimization: stop early if we have enough read and are past the unread ones
}
// Basic checks: must have a related item and be triggered by someone else
if (!tx.relatedItemId || tx.userId === currentUser.id) {
continue;
}
// Check if the transaction type indicates a notification-worthy event
const isRelevantType = tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION' || tx.type === 'WISH_REDEMPTION';
if (!isRelevantType) {
continue;
}
// Check if the related item is shared with the current user
let isShared = false;
const isHabitCompletion = tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION';
const isWishRedemption = tx.type === 'WISH_REDEMPTION';
if (isHabitCompletion) {
const habit = habitsData.habits.find(h => h.id === tx.relatedItemId);
if (habit?.userIds?.includes(currentUser.id) && tx.userId && habit.userIds.includes(tx.userId)) {
isShared = true;
}
} else if (isWishRedemption) {
const wish = wishlistData.items.find(w => w.id === tx.relatedItemId);
if (wish?.userIds?.includes(currentUser.id) && tx.userId && wish.userIds.includes(tx.userId)) {
isShared = true;
}
}
if (!isShared) {
continue; // Skip if not shared
}
// Transaction is relevant, determine if read or unread
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
if (!lastReadTimestamp || txTimestamp > lastReadTimestamp) {
unread.push(tx);
} else if (read.length < MAX_READ_NOTIFICATIONS) {
// Only add to read if we haven't hit the limit
read.push(tx);
}
}
// Transactions are assumed to be sorted newest first from the source
return { unreadNotifications: unread, displayedReadNotifications: read };
}, [coinsData.transactions, habitsData.habits, wishlistData.items, currentUser]);
// --- End Calculate Notifications ---
const unreadCount = unreadNotifications.length;
const handleNotificationClick = async () => {
if (!currentUser || !currentUser.id || unreadCount === 0) return; // Only update if there are unread notifications
try {
const nowTimestamp = d2t({ dateTime: getNow({}) });
await updateLastNotificationReadTimestamp(currentUser.id, nowTimestamp);
} catch (error) {
console.error("Failed to update notification read timestamp:", error);
}
};
return (
<DropdownMenu onOpenChange={(open) => {
// Update timestamp only when opening the dropdown and there are unread notifications
if (open && unreadCount > 0) {
handleNotificationClick();
}
}}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Notifications" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 block h-2 w-2 rounded-full bg-red-500 ring-1 ring-white dark:ring-gray-800" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
<NotificationDropdown
currentUser={currentUser as User | null} // Cast needed as useHelpers can return undefined initially
unreadNotifications={unreadNotifications}
displayedReadNotifications={displayedReadNotifications}
habitsData={habitsData} // Pass necessary data down
wishlistData={wishlistData}
usersData={usersData}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CoinsData, HabitsData, WishlistData, UserData, User, CoinTransaction } from '@/lib/types';
import { t2d } from '@/lib/utils';
import Link from 'next/link';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Info } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface NotificationDropdownProps {
currentUser: User | null;
unreadNotifications: CoinTransaction[];
displayedReadNotifications: CoinTransaction[];
habitsData: HabitsData; // Keep needed props
wishlistData: WishlistData;
usersData: UserData;
}
// Helper function to generate notification message
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => {
const username = triggeringUser?.username || 'Someone';
const itemName = relatedItemName || 'a shared item';
switch (tx.type) {
case 'HABIT_COMPLETION':
case 'TASK_COMPLETION':
return `${username} completed ${itemName}.`;
case 'WISH_REDEMPTION':
return `${username} redeemed ${itemName}.`;
// Add other relevant transaction types if needed
default:
return `Activity related to ${itemName} by ${username}.`; // Fallback message
}
};
// Helper function to get the name of the related item
const getRelatedItemName = (tx: CoinTransaction, habitsData: HabitsData, wishlistData: WishlistData): string | undefined => {
if (!tx.relatedItemId) return undefined;
if (tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION') {
return habitsData.habits.find(h => h.id === tx.relatedItemId)?.name;
}
if (tx.type === 'WISH_REDEMPTION') {
return wishlistData.items.find(w => w.id === tx.relatedItemId)?.name;
}
return undefined;
};
export default function NotificationDropdown({
currentUser,
unreadNotifications, // Use props directly
displayedReadNotifications, // Use props directly
habitsData,
wishlistData,
usersData,
}: NotificationDropdownProps) {
if (!currentUser) {
return <div className="p-4 text-sm text-gray-500">Not logged in.</div>;
}
// Removed the useMemo block for calculating notifications
const renderNotification = (tx: CoinTransaction, isUnread: boolean) => {
const triggeringUser = usersData.users.find(u => u.id === tx.userId);
const relatedItemName = getRelatedItemName(tx, habitsData, wishlistData);
const message = getNotificationMessage(tx, triggeringUser, relatedItemName);
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
const timeAgo = txTimestamp.toRelative(); // e.g., "2 hours ago"
// Add the triggering user's ID to the query params if it exists
const linkHref = `/coins?highlight=${tx.id}${tx.userId ? `&user=${tx.userId}` : ''}`;
return (
// Wrap the Link with DropdownMenuItem and use asChild to pass props
<DropdownMenuItem key={tx.id} asChild className={`p-0 focus:bg-inherit dark:focus:bg-inherit cursor-pointer`}>
<Link href={linkHref} className={`block hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${isUnread ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`} scroll={true}>
<div className="p-3 flex items-start gap-3">
<Avatar className="h-8 w-8 mt-1">
<AvatarImage src={triggeringUser?.avatarPath ? `/api/avatars/${triggeringUser.avatarPath.split('/').pop()}` : undefined} alt={triggeringUser?.username} />
<AvatarFallback>{triggeringUser?.username?.charAt(0).toUpperCase() || '?'}</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className={`text-sm ${isUnread ? 'font-semibold' : ''}`}>{message}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{timeAgo}</p>
</div>
</div>
</Link>
</DropdownMenuItem>
);
};
return (
<TooltipProvider>
{/* Removed the outer div as width is now set on DropdownMenuContent in NotificationBell */}
<>
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<h4 className="text-sm font-medium">Notifications</h4>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<p className="text-xs">
Shows completions or redemptions by other users for habits or wishlist that you shared with them (you must be admin)
</p>
</TooltipContent>
</Tooltip>
</div>
<ScrollArea className="h-[400px]">
{unreadNotifications.length === 0 && displayedReadNotifications.length === 0 && (
<div className="p-4 text-center text-sm text-gray-500">No notifications yet.</div>
)}
{unreadNotifications.length > 0 && (
<>
{unreadNotifications.map(tx => renderNotification(tx, true))}
{displayedReadNotifications.length > 0 && <Separator className="my-2" />}
</>
)}
{displayedReadNotifications.length > 0 && (
<>
{displayedReadNotifications.map(tx => renderNotification(tx, false))}
</>
)}
</ScrollArea>
</> {/* Close the fragment */}
</TooltipProvider>
);
}

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -9,6 +9,7 @@ import {
getDefaultUsersData,
CompletionCache,
getDefaultServerSettings,
User,
} from "./types";
import {
getTodayInTimezone,
@@ -110,16 +111,16 @@ export const completionCacheAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const timezone = get(settingsAtom).system.timezone;
const cache: CompletionCache = {};
habits.forEach(habit => {
habit.completions.forEach(utcTimestamp => {
const localDate = t2d({ timestamp: utcTimestamp, timezone })
.toFormat('yyyy-MM-dd');
if (!cache[localDate]) {
cache[localDate] = {};
}
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
});
});
@@ -173,12 +174,12 @@ export const hasTasksAtom = atom((get) => {
})
// Atom family for habits by specific date
export const habitsByDateFamily = atomFamily((dateString: string) =>
export const habitsByDateFamily = atomFamily((dateString: string) =>
atom((get) => {
const habits = get(habitsAtom).habits;
const settings = get(settingsAtom);
const timezone = settings.system.timezone;
const date = DateTime.fromISO(dateString).setZone(timezone);
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
})

View File

@@ -31,7 +31,8 @@ export type SafeUser = SessionUser & {
}
export type User = SafeUser & {
password: string
password?: string // Optional: Allow users without passwords (e.g., initial setup)
lastNotificationReadTimestamp?: string // UTC ISO date string
}
export type Habit = {
@@ -101,8 +102,9 @@ export const getDefaultUsersData = (): UserData => ({
{
id: uuid(),
username: 'admin',
password: '',
// password: '', // No default password for admin initially? Or set a secure default?
isAdmin: true,
lastNotificationReadTimestamp: undefined, // Initialize as undefined
}
]
});

268
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "habittrove",
"version": "0.2.4",
"version": "0.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "habittrove",
"version": "0.2.4",
"version": "0.2.7",
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
@@ -18,7 +18,9 @@
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
@@ -1527,6 +1529,189 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.4.tgz",
"integrity": "sha512-G9rdWTQjOR4sk76HwSdROhPU0jZWpfozn9skU1v4N0/g9k7TmswrJn8W8WMU+aYktnLLpk5LX6fofj2bGe5NFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.3",
"@radix-ui/react-primitive": "2.0.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz",
"integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz",
"integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz",
@@ -1569,6 +1754,85 @@
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.3.tgz",
"integrity": "sha512-2omrWKJvxR0U/tkIXezcc1nFMwtLU0+b/rDK40gnzJqTLWQ/TD/D5IYVefp9sC3QWfeQbpSbEA6op9MQKyaALQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz",
"integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "habittrove",
"version": "0.2.7",
"version": "0.2.8",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -25,7 +25,9 @@
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",