mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-20 22:24:28 +01:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a615a45c39 | ||
|
|
dea2b30c3b | ||
|
|
ea0203dc86 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,6 +41,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# customize
|
||||
data/*
|
||||
/data/*
|
||||
/data.*/*
|
||||
Budfile
|
||||
certificates
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -1,5 +1,30 @@
|
||||
# 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
|
||||
|
||||
* persist "show all" settings in browser (#72)
|
||||
|
||||
### Fixed
|
||||
|
||||
* nav bar spacing
|
||||
* completion count badge
|
||||
|
||||
## Version 0.2.1
|
||||
|
||||
### Changed
|
||||
|
||||
* Added bottom padding for nav bar on iOS devices (#63)
|
||||
|
||||
## Version 0.2.0
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
await saveUsersData(newData)
|
||||
}
|
||||
|
||||
export async function loadServerSettings(): Promise<ServerSettings> {
|
||||
return {
|
||||
isDemo: !!process.env.NEXT_PUBLIC_DEMO,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import HabitCalendar from '@/components/HabitCalendar'
|
||||
import { ViewToggle } from '@/components/ViewToggle'
|
||||
import CompletionCountBadge from '@/components/CompletionCountBadge'
|
||||
|
||||
export default function CalendarPage() {
|
||||
return (
|
||||
|
||||
70
app/debug/habits/page.tsx
Normal file
70
app/debug/habits/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useHabits } from "@/hooks/useHabits";
|
||||
import { habitsAtom, settingsAtom } from "@/lib/atoms";
|
||||
import { Habit } from "@/lib/types";
|
||||
import { useAtom } from "jotai";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
|
||||
|
||||
type CompletionCache = {
|
||||
[dateKey: string]: { // dateKey format: "YYYY-MM-DD"
|
||||
[habitId: string]: number // number of completions on that date
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default function DebugPage() {
|
||||
const [habits] = useAtom(habitsAtom);
|
||||
const [settings] = useAtom(settingsAtom);
|
||||
|
||||
function buildCompletionCache(habits: Habit[], timezone: string): CompletionCache {
|
||||
const cache: CompletionCache = {};
|
||||
|
||||
habits.forEach(habit => {
|
||||
habit.completions.forEach(utcTimestamp => {
|
||||
// Convert UTC timestamp to local date string in specified timezone
|
||||
const localDate = DateTime
|
||||
.fromISO(utcTimestamp)
|
||||
.setZone(timezone)
|
||||
.toFormat('yyyy-MM-dd');
|
||||
|
||||
if (!cache[localDate]) {
|
||||
cache[localDate] = {};
|
||||
}
|
||||
|
||||
// Increment completion count for this habit on this date
|
||||
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
function getCompletedHabitsForDate(
|
||||
habits: Habit[],
|
||||
date: DateTime,
|
||||
timezone: string,
|
||||
completionCache: CompletionCache
|
||||
): Habit[] {
|
||||
const dateKey = date.setZone(timezone).toFormat('yyyy-MM-dd');
|
||||
const dateCompletions = completionCache[dateKey] || {};
|
||||
|
||||
return habits.filter(habit => {
|
||||
const completionsNeeded = habit.targetCompletions || 1;
|
||||
const completionsAchieved = dateCompletions[habit.id] || 0;
|
||||
return completionsAchieved >= completionsNeeded;
|
||||
});
|
||||
}
|
||||
|
||||
const habitCache = buildCompletionCache(habits.habits, settings.system.timezone);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-xl font-bold mb-4">Debug Page</h1>
|
||||
<div className="bg-gray-100 p-4 rounded break-all">
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<ThemeProvider
|
||||
|
||||
@@ -16,8 +16,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import { Habit, SafeUser } from '@/lib/types'
|
||||
import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
|
||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES } from '@/lib/constants'
|
||||
import { d2s, d2t, getFrequencyDisplayText, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
|
||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
||||
import * as chrono from 'chrono-node';
|
||||
import { DateTime } from 'luxon'
|
||||
import {
|
||||
@@ -43,15 +43,33 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||
const isRecurRule = !isTask
|
||||
const origRuleText = 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 now = getNow({ timezone: settings.system.timezone })
|
||||
const { currentUser } = useHelpers()
|
||||
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const users = usersData.users
|
||||
|
||||
function getFrequencyUpdate() {
|
||||
if (ruleText === origRuleText && habit?.frequency) {
|
||||
return habit.frequency
|
||||
}
|
||||
if (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
|
||||
<Avatar
|
||||
key={user.id}
|
||||
className={`h-8 w-8 border-2 cursor-pointer
|
||||
${selectedUserIds.includes(user.id)
|
||||
? 'border-primary'
|
||||
${selectedUserIds.includes(user.id)
|
||||
? 'border-primary'
|
||||
: 'border-muted'
|
||||
}`}
|
||||
title={user.username}
|
||||
onClick={() => {
|
||||
setSelectedUserIds(prev =>
|
||||
setSelectedUserIds(prev =>
|
||||
prev.includes(user.id)
|
||||
? prev.filter(id => id !== user.id)
|
||||
: [...prev, user.id]
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Habit } from '@/lib/types'
|
||||
import { isHabitDue, getCompletionsForDate } from '@/lib/utils'
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useAtom } from 'jotai'
|
||||
import { completedHabitsMapAtom, habitsAtom, habitsByDateFamily } from '@/lib/atoms'
|
||||
import { getTodayInTimezone } from '@/lib/utils'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
|
||||
interface CompletionCountBadgeProps {
|
||||
habits: Habit[]
|
||||
selectedDate: luxon.DateTime
|
||||
timezone: string
|
||||
type: 'tasks' | 'habits'
|
||||
type: 'habits' | 'tasks'
|
||||
date?: string
|
||||
}
|
||||
|
||||
export function CompletionCountBadge({ habits, selectedDate, timezone, type }: CompletionCountBadgeProps) {
|
||||
const filteredHabits = habits.filter(habit => {
|
||||
const isTask = type === 'tasks'
|
||||
if ((habit.isTask === isTask) && isHabitDue({
|
||||
habit,
|
||||
timezone,
|
||||
date: selectedDate
|
||||
})) {
|
||||
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone })
|
||||
return completions >= (habit.targetCompletions || 1)
|
||||
}
|
||||
return false
|
||||
}).length
|
||||
export default function CompletionCountBadge({
|
||||
type,
|
||||
date
|
||||
}: CompletionCountBadgeProps) {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||
const targetDate = date || getTodayInTimezone(settings.system.timezone)
|
||||
const [dueHabits] = useAtom(habitsByDateFamily(targetDate))
|
||||
|
||||
const totalHabits = habits.filter(habit =>
|
||||
(habit.isTask === (type === 'tasks')) &&
|
||||
isHabitDue({
|
||||
habit,
|
||||
timezone,
|
||||
date: selectedDate
|
||||
})
|
||||
const completedCount = completedHabitsMap.get(targetDate)?.filter(h =>
|
||||
type === 'tasks' ? h.isTask : !h.isTask
|
||||
).length || 0
|
||||
|
||||
const totalCount = dueHabits.filter(h =>
|
||||
type === 'tasks' ? h.isTask : !h.isTask
|
||||
).length
|
||||
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{`${filteredHabits}/${totalHabits} Completed`}
|
||||
{`${completedCount}/${totalCount} Completed`}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus } from 'lucide-react'
|
||||
import CompletionCountBadge from './CompletionCountBadge'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -9,7 +10,7 @@ import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms'
|
||||
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom, dailyHabitsAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -34,29 +35,15 @@ export default function DailyOverview({
|
||||
}: UpcomingItemsProps) {
|
||||
const { completeHabit, undoComplete } = useHabits()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
|
||||
const [dailyTasks, setDailyTasks] = useState<Habit[]>([])
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||
const [dailyItems] = useAtom(dailyHabitsAtom)
|
||||
const dailyTasks = dailyItems.filter(habit => habit.isTask)
|
||||
const dailyHabits = dailyItems.filter(habit => !habit.isTask)
|
||||
const today = getTodayInTimezone(settings.system.timezone)
|
||||
const todayCompletions = completedHabitsMap.get(today) || []
|
||||
const { saveHabit } = useHabits()
|
||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
||||
|
||||
useEffect(() => {
|
||||
// Filter habits and tasks that are due today and not archived
|
||||
const filteredHabits = habits.filter(habit =>
|
||||
!habit.isTask &&
|
||||
!habit.archived &&
|
||||
isHabitDueToday({ habit, timezone: settings.system.timezone })
|
||||
)
|
||||
const filteredTasks = habits.filter(habit =>
|
||||
habit.isTask &&
|
||||
isHabitDueToday({ habit, timezone: settings.system.timezone })
|
||||
)
|
||||
setDailyHabits(filteredHabits)
|
||||
setDailyTasks(filteredTasks)
|
||||
}, [habits])
|
||||
|
||||
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
|
||||
// Filter out archived wishlist items
|
||||
const sortedWishlistItems = wishlistItems
|
||||
@@ -74,9 +61,6 @@ export default function DailyOverview({
|
||||
return a.coinCost - b.coinCost
|
||||
})
|
||||
|
||||
const [expandedHabits, setExpandedHabits] = useState(false)
|
||||
const [expandedTasks, setExpandedTasks] = useState(false)
|
||||
const [expandedWishlist, setExpandedWishlist] = useState(false)
|
||||
const [hasTasks] = useAtom(hasTasksAtom)
|
||||
const [_, setPomo] = useAtom(pomodoroAtom)
|
||||
const [modalConfig, setModalConfig] = useState<{
|
||||
@@ -126,13 +110,7 @@ export default function DailyOverview({
|
||||
<h3 className="font-semibold">Daily Tasks</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{`${dailyTasks.filter(task => {
|
||||
const completions = (completedHabitsMap.get(today) || [])
|
||||
.filter(h => h.id === task.id).length;
|
||||
return completions >= (task.targetCompletions || 1);
|
||||
}).length}/${dailyTasks.length} Completed`}
|
||||
</Badge>
|
||||
<CompletionCountBadge type="tasks" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -149,7 +127,7 @@ export default function DailyOverview({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedTasks ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${browserSettings.expandedTasks ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
{dailyTasks
|
||||
.sort((a, b) => {
|
||||
// First by completion status
|
||||
@@ -177,7 +155,7 @@ export default function DailyOverview({
|
||||
const bTarget = b.targetCompletions || 1;
|
||||
return bTarget - aTarget;
|
||||
})
|
||||
.slice(0, expandedTasks ? undefined : 5)
|
||||
.slice(0, browserSettings.expandedTasks ? undefined : 5)
|
||||
.map((habit) => {
|
||||
const completionsToday = habit.completions.filter(completion =>
|
||||
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
||||
@@ -190,10 +168,10 @@ export default function DailyOverview({
|
||||
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
|
||||
key={habit.id}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex-none">
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -226,7 +204,7 @@ export default function DailyOverview({
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<span className={isCompleted ? 'line-through' : ''}>
|
||||
<span className={cn(isCompleted ? 'line-through' : '', 'break-all')}>
|
||||
<Linkify>
|
||||
{habit.name}
|
||||
</Linkify>
|
||||
@@ -245,7 +223,7 @@ export default function DailyOverview({
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</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 && (
|
||||
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
|
||||
{completionsToday}/{target}
|
||||
@@ -279,10 +257,10 @@ export default function DailyOverview({
|
||||
</ul>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpandedTasks(!expandedTasks)}
|
||||
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedTasks: !prev.expandedTasks }))}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
{expandedTasks ? (
|
||||
{browserSettings.expandedTasks ? (
|
||||
<>
|
||||
Show less
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
@@ -337,13 +315,7 @@ export default function DailyOverview({
|
||||
<h3 className="font-semibold">Daily Habits</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{`${dailyHabits.filter(habit => {
|
||||
const completions = (completedHabitsMap.get(today) || [])
|
||||
.filter(h => h.id === habit.id).length;
|
||||
return completions >= (habit.targetCompletions || 1);
|
||||
}).length}/${dailyHabits.length} Completed`}
|
||||
</Badge>
|
||||
<CompletionCountBadge type="habits" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -360,7 +332,7 @@ export default function DailyOverview({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${browserSettings.expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
{dailyHabits
|
||||
.sort((a, b) => {
|
||||
// First by completion status
|
||||
@@ -388,7 +360,7 @@ export default function DailyOverview({
|
||||
const bTarget = b.targetCompletions || 1;
|
||||
return bTarget - aTarget;
|
||||
})
|
||||
.slice(0, expandedHabits ? undefined : 5)
|
||||
.slice(0, browserSettings.expandedHabits ? undefined : 5)
|
||||
.map((habit) => {
|
||||
const completionsToday = habit.completions.filter(completion =>
|
||||
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
||||
@@ -401,10 +373,10 @@ export default function DailyOverview({
|
||||
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
|
||||
key={habit.id}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex-none">
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -437,7 +409,7 @@ export default function DailyOverview({
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<span className={isCompleted ? 'line-through' : ''}>
|
||||
<span className={cn(isCompleted ? 'line-through' : '', 'break-all')}>
|
||||
<Linkify>
|
||||
{habit.name}
|
||||
</Linkify>
|
||||
@@ -456,7 +428,7 @@ export default function DailyOverview({
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</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 && (
|
||||
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
|
||||
{completionsToday}/{target}
|
||||
@@ -490,10 +462,10 @@ export default function DailyOverview({
|
||||
</ul>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpandedHabits(!expandedHabits)}
|
||||
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedHabits: !prev.expandedHabits }))}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
{expandedHabits ? (
|
||||
{browserSettings.expandedHabits ? (
|
||||
<>
|
||||
Show less
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
@@ -525,7 +497,7 @@ export default function DailyOverview({
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
<div className={`space-y-3 transition-all duration-300 ease-in-out ${browserSettings.expandedWishlist ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
{sortedWishlistItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-4">
|
||||
No wishlist items yet. Add some goals to work towards!
|
||||
@@ -533,7 +505,7 @@ export default function DailyOverview({
|
||||
) : (
|
||||
<>
|
||||
{sortedWishlistItems
|
||||
.slice(0, expandedWishlist ? undefined : 5)
|
||||
.slice(0, browserSettings.expandedWishlist ? undefined : 5)
|
||||
.map((item) => {
|
||||
const isRedeemable = item.coinCost <= coinBalance
|
||||
return (
|
||||
@@ -587,10 +559,10 @@ export default function DailyOverview({
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpandedWishlist(!expandedWishlist)}
|
||||
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
{expandedWishlist ? (
|
||||
{browserSettings.expandedWishlist ? (
|
||||
<>
|
||||
Show less
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { CompletionCountBadge } from '@/components/CompletionCountBadge'
|
||||
import CompletionCountBadge from '@/components/CompletionCountBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, Circle, CircleCheck } from 'lucide-react'
|
||||
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
|
||||
@@ -25,7 +25,8 @@ export default function HabitCalendar() {
|
||||
}
|
||||
}, [completePastHabit])
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [selectedDate, setSelectedDate] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
|
||||
const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd")
|
||||
const [habitsData] = useAtom(habitsAtom)
|
||||
const [hasTasks] = useAtom(hasTasksAtom)
|
||||
const habits = habitsData.habits
|
||||
@@ -50,8 +51,8 @@ export default function HabitCalendar() {
|
||||
<CardContent>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate.toJSDate()}
|
||||
onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))}
|
||||
selected={selectedDateTime.toJSDate()}
|
||||
onSelect={(e) => e && setSelectedDateTime(DateTime.fromJSDate(e))}
|
||||
weekStartsOn={settings.system.weekStartDay}
|
||||
className="rounded-md border"
|
||||
modifiers={{
|
||||
@@ -71,36 +72,31 @@ export default function HabitCalendar() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{selectedDate ? (
|
||||
<>{d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
|
||||
{selectedDateTime ? (
|
||||
<>{d2s({ dateTime: selectedDateTime, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
|
||||
) : (
|
||||
'Select a date'
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedDate && (
|
||||
{selectedDateTime && (
|
||||
<div className="space-y-8">
|
||||
{hasTasks && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Tasks</h3>
|
||||
<CompletionCountBadge
|
||||
habits={habits}
|
||||
selectedDate={selectedDate}
|
||||
timezone={settings.system.timezone}
|
||||
type="tasks"
|
||||
/>
|
||||
<CompletionCountBadge type="tasks" date={selectedDate.toString()} />
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{habits
|
||||
.filter(habit => habit.isTask && isHabitDue({
|
||||
habit,
|
||||
timezone: settings.system.timezone,
|
||||
date: selectedDate
|
||||
date: selectedDateTime
|
||||
}))
|
||||
.map((habit) => {
|
||||
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone })
|
||||
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
|
||||
const isCompleted = completions >= (habit.targetCompletions || 1)
|
||||
return (
|
||||
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
|
||||
@@ -115,7 +111,7 @@ export default function HabitCalendar() {
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCompletePastHabit(habit, selectedDate)}
|
||||
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
|
||||
disabled={isCompleted}
|
||||
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
|
||||
>
|
||||
@@ -149,22 +145,17 @@ export default function HabitCalendar() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Habits</h3>
|
||||
<CompletionCountBadge
|
||||
habits={habits}
|
||||
selectedDate={selectedDate}
|
||||
timezone={settings.system.timezone}
|
||||
type="habits"
|
||||
/>
|
||||
<CompletionCountBadge type="habits" date={selectedDate.toString()} />
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{habits
|
||||
.filter(habit => !habit.isTask && !habit.archived && isHabitDue({
|
||||
.filter(habit => !habit.isTask && isHabitDue({
|
||||
habit,
|
||||
timezone: settings.system.timezone,
|
||||
date: selectedDate
|
||||
date: selectedDateTime
|
||||
}))
|
||||
.map((habit) => {
|
||||
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone })
|
||||
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
|
||||
const isCompleted = completions >= (habit.targetCompletions || 1)
|
||||
return (
|
||||
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
|
||||
@@ -179,7 +170,7 @@ export default function HabitCalendar() {
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCompletePastHabit(habit, selectedDate)}
|
||||
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
|
||||
disabled={isCompleted}
|
||||
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
)}
|
||||
</CardHeader>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AboutModal from './AboutModal'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
|
||||
type ViewPort = 'main' | 'mobile'
|
||||
|
||||
@@ -33,6 +34,7 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
const [isMobileView, setIsMobileView] = useState(false)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const { isIOS } = useHelpers()
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -52,14 +54,14 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
if (viewPort === 'mobile' && isMobileView) {
|
||||
return (
|
||||
<>
|
||||
<div className="pb-16" /> {/* Add padding at the bottom to prevent content from being hidden */}
|
||||
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg">
|
||||
<div className="flex justify-around">
|
||||
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
|
||||
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
||||
<div className="grid grid-cols-5 w-full">
|
||||
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="flex flex-col items-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
|
||||
@@ -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<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 [avatarFile, setAvatarFile] = useState<File | null>(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 && (
|
||||
<p className="text-sm text-red-500">Password is automatically disabled in demo instance</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ViewToggle({
|
||||
|
||||
// Calculate due tasks count
|
||||
const dueTasksCount = habits.habits.filter(habit =>
|
||||
habit.isTask && !habit.archived && isHabitDueToday({ habit, timezone: settings.system.timezone })
|
||||
habit.isTask && isHabitDueToday({ habit, timezone: settings.system.timezone })
|
||||
).length
|
||||
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
105
lib/atoms.ts
105
lib/atoms.ts
@@ -7,6 +7,8 @@ import {
|
||||
Habit,
|
||||
ViewType,
|
||||
getDefaultUsersData,
|
||||
CompletionCache,
|
||||
getDefaultServerSettings,
|
||||
} from "./types";
|
||||
import {
|
||||
getTodayInTimezone,
|
||||
@@ -18,16 +20,26 @@ import {
|
||||
calculateCoinsSpentToday,
|
||||
calculateTransactionsToday,
|
||||
getCompletionsForToday,
|
||||
getISODate
|
||||
getISODate,
|
||||
isHabitDueToday,
|
||||
getNow,
|
||||
isHabitDue
|
||||
} from "@/lib/utils";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export interface BrowserSettings {
|
||||
viewType: ViewType
|
||||
expandedHabits: boolean
|
||||
expandedTasks: boolean
|
||||
expandedWishlist: boolean
|
||||
}
|
||||
|
||||
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
||||
viewType: 'habits'
|
||||
viewType: 'habits',
|
||||
expandedHabits: false,
|
||||
expandedTasks: false,
|
||||
expandedWishlist: false
|
||||
} as BrowserSettings)
|
||||
|
||||
export const usersAtom = atom(getDefaultUsersData())
|
||||
@@ -35,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) => {
|
||||
@@ -92,36 +105,49 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
||||
|
||||
export const userSelectAtom = atom<boolean>(false)
|
||||
|
||||
// Derived atom for *fully* completed habits by date, respecting target completions
|
||||
export const completedHabitsMapAtom = atom((get) => {
|
||||
const habits = get(habitsAtom).habits
|
||||
const timezone = get(settingsAtom).system.timezone
|
||||
|
||||
const map = new Map<string, Habit[]>()
|
||||
|
||||
// Derived atom for completion cache
|
||||
export const completionCacheAtom = atom((get) => {
|
||||
const habits = get(habitsAtom).habits;
|
||||
const timezone = get(settingsAtom).system.timezone;
|
||||
const cache: CompletionCache = {};
|
||||
|
||||
habits.forEach(habit => {
|
||||
// Group completions by date
|
||||
const completionsByDate = new Map<string, number>()
|
||||
|
||||
habit.completions.forEach(completion => {
|
||||
const dateKey = getISODate({ dateTime: t2d({ timestamp: completion, timezone }), timezone })
|
||||
completionsByDate.set(dateKey, (completionsByDate.get(dateKey) || 0) + 1)
|
||||
})
|
||||
|
||||
// Check if habit meets target completions for each date
|
||||
completionsByDate.forEach((count, dateKey) => {
|
||||
const target = habit.targetCompletions || 1
|
||||
if (count >= target) {
|
||||
if (!map.has(dateKey)) {
|
||||
map.set(dateKey, [])
|
||||
}
|
||||
map.get(dateKey)!.push(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;
|
||||
});
|
||||
});
|
||||
|
||||
return map
|
||||
})
|
||||
return cache;
|
||||
});
|
||||
|
||||
// Derived atom for completed habits by date, using the cache
|
||||
export const completedHabitsMapAtom = atom((get) => {
|
||||
const habits = get(habitsAtom).habits;
|
||||
const completionCache = get(completionCacheAtom);
|
||||
const map = new Map<string, Habit[]>();
|
||||
|
||||
// For each date in the cache
|
||||
Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => {
|
||||
const completedHabits = habits.filter(habit => {
|
||||
const completionsNeeded = habit.targetCompletions || 1;
|
||||
const completionsAchieved = habitCompletions[habit.id] || 0;
|
||||
return completionsAchieved >= completionsNeeded;
|
||||
});
|
||||
|
||||
if (completedHabits.length > 0) {
|
||||
map.set(dateKey, completedHabits);
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
|
||||
export const pomodoroTodayCompletionsAtom = atom((get) => {
|
||||
@@ -145,3 +171,22 @@ export const hasTasksAtom = atom((get) => {
|
||||
const habits = get(habitsAtom)
|
||||
return habits.habits.some(habit => habit.isTask === true)
|
||||
})
|
||||
|
||||
// Atom family for habits by specific date
|
||||
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 }));
|
||||
})
|
||||
);
|
||||
|
||||
// Derived atom for daily habits
|
||||
export const dailyHabitsAtom = atom((get) => {
|
||||
const settings = get(settingsAtom);
|
||||
const today = getTodayInTimezone(settings.system.timezone);
|
||||
return get(habitsByDateFamily(today));
|
||||
});
|
||||
|
||||
@@ -12,6 +12,19 @@ export function useHelpers() {
|
||||
const currentUserId = session?.user.id
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const currentUser = usersData.users.find((u) => u.id === currentUserId)
|
||||
// detect iOS: https://stackoverflow.com/a/9039885
|
||||
function iOS() {
|
||||
return [
|
||||
'iPad Simulator',
|
||||
'iPhone Simulator',
|
||||
'iPod Simulator',
|
||||
'iPad',
|
||||
'iPhone',
|
||||
'iPod',
|
||||
].includes(navigator.platform)
|
||||
// iPad on iOS 13 detection
|
||||
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||
}
|
||||
|
||||
return {
|
||||
currentUserId,
|
||||
@@ -19,6 +32,7 @@ export function useHelpers() {
|
||||
usersData,
|
||||
status,
|
||||
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin ||
|
||||
checkPermission(currentUser?.permissions, resource, action)
|
||||
checkPermission(currentUser?.permissions, resource, action),
|
||||
isIOS: iOS(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,9 @@ export function init() {
|
||||
)
|
||||
.join("\n ")
|
||||
|
||||
console.error(
|
||||
throw new Error(
|
||||
`Missing environment variables:\n ${errorMessage}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
lib/types.ts
15
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,
|
||||
@@ -164,6 +168,12 @@ export interface Settings {
|
||||
profile: ProfileSettings;
|
||||
}
|
||||
|
||||
export type CompletionCache = {
|
||||
[dateKey: string]: { // dateKey format: "YYYY-MM-DD"
|
||||
[habitId: string]: number // number of completions on that date
|
||||
}
|
||||
}
|
||||
|
||||
export type ViewType = 'habits' | 'tasks'
|
||||
|
||||
export interface JotaiHydrateInitialValues {
|
||||
@@ -172,4 +182,9 @@ export interface JotaiHydrateInitialValues {
|
||||
habits: HabitsData;
|
||||
wishlist: WishlistData;
|
||||
users: UserData;
|
||||
serverSettings: ServerSettings;
|
||||
}
|
||||
|
||||
export interface ServerSettings {
|
||||
isDemo: boolean
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
63
lib/utils.ts
63
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,
|
||||
@@ -231,17 +258,29 @@ export function isHabitDue({
|
||||
timezone: string
|
||||
date: DateTime
|
||||
}): boolean {
|
||||
// handle task
|
||||
if (habit.isTask) {
|
||||
// For tasks, frequency is stored as a UTC ISO timestamp
|
||||
const taskDueDate = t2d({ timestamp: habit.frequency, timezone })
|
||||
return isSameDate(taskDueDate, date);
|
||||
}
|
||||
|
||||
// handle habit
|
||||
if (habit.archived) {
|
||||
return false
|
||||
}
|
||||
|
||||
const startOfDay = date.setZone(timezone).startOf('day')
|
||||
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)
|
||||
@@ -289,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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Reference in New Issue
Block a user