Compare commits

..

1 Commits

Author SHA1 Message Date
dohsimpson
dea2b30c3b fix completion badge 2025-02-21 18:16:15 -05:00
12 changed files with 231 additions and 135 deletions

View File

@@ -1,5 +1,16 @@
# Changelog # Changelog
## Version 0.2.2
### Changed
* persist "show all" settings in browser (#72)
### Fixed
* nav bar spacing
* completion count badge
## Version 0.2.1 ## Version 0.2.1
### Changed ### Changed

View File

@@ -1,6 +1,7 @@
import Layout from '@/components/Layout' import Layout from '@/components/Layout'
import HabitCalendar from '@/components/HabitCalendar' import HabitCalendar from '@/components/HabitCalendar'
import { ViewToggle } from '@/components/ViewToggle' import { ViewToggle } from '@/components/ViewToggle'
import CompletionCountBadge from '@/components/CompletionCountBadge'
export default function CalendarPage() { export default function CalendarPage() {
return ( return (

70
app/debug/habits/page.tsx Normal file
View 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>
);
}

View File

@@ -1,40 +1,35 @@
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge"
import { Habit } from '@/lib/types' import { useAtom } from 'jotai'
import { isHabitDue, getCompletionsForDate } from '@/lib/utils' import { completedHabitsMapAtom, habitsAtom, habitsByDateFamily } from '@/lib/atoms'
import { getTodayInTimezone } from '@/lib/utils'
import { useHabits } from '@/hooks/useHabits'
import { settingsAtom } from '@/lib/atoms'
interface CompletionCountBadgeProps { interface CompletionCountBadgeProps {
habits: Habit[] type: 'habits' | 'tasks'
selectedDate: luxon.DateTime date?: string
timezone: string
type: 'tasks' | 'habits'
} }
export function CompletionCountBadge({ habits, selectedDate, timezone, type }: CompletionCountBadgeProps) { export default function CompletionCountBadge({
const filteredHabits = habits.filter(habit => { type,
const isTask = type === 'tasks' date
if ((habit.isTask === isTask) && isHabitDue({ }: CompletionCountBadgeProps) {
habit, const [settings] = useAtom(settingsAtom)
timezone, const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
date: selectedDate const targetDate = date || getTodayInTimezone(settings.system.timezone)
})) { const [dueHabits] = useAtom(habitsByDateFamily(targetDate))
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone })
return completions >= (habit.targetCompletions || 1)
}
return false
}).length
const totalHabits = habits.filter(habit => const completedCount = completedHabitsMap.get(targetDate)?.filter(h =>
(habit.isTask === (type === 'tasks')) && type === 'tasks' ? h.isTask : !h.isTask
isHabitDue({ ).length || 0
habit,
timezone, const totalCount = dueHabits.filter(h =>
date: selectedDate type === 'tasks' ? h.isTask : !h.isTask
})
).length ).length
return ( return (
<Badge variant="secondary"> <Badge variant="secondary">
{`${filteredHabits}/${totalHabits} Completed`} {`${completedCount}/${totalCount} Completed`}
</Badge> </Badge>
) )
} }

View File

@@ -1,4 +1,5 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus } from 'lucide-react' import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus } from 'lucide-react'
import CompletionCountBadge from './CompletionCountBadge'
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -9,7 +10,7 @@ import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
import Link from 'next/link' import Link from 'next/link'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAtom } from 'jotai' 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 { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -34,29 +35,15 @@ export default function DailyOverview({
}: UpcomingItemsProps) { }: UpcomingItemsProps) {
const { completeHabit, undoComplete } = useHabits() const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
const [dailyTasks, setDailyTasks] = useState<Habit[]>([])
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) 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 today = getTodayInTimezone(settings.system.timezone)
const todayCompletions = completedHabitsMap.get(today) || [] const todayCompletions = completedHabitsMap.get(today) || []
const { saveHabit } = useHabits() const { saveHabit } = useHabits()
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom) 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 // Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
// Filter out archived wishlist items // Filter out archived wishlist items
const sortedWishlistItems = wishlistItems const sortedWishlistItems = wishlistItems
@@ -74,9 +61,6 @@ export default function DailyOverview({
return a.coinCost - b.coinCost 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 [hasTasks] = useAtom(hasTasksAtom)
const [_, setPomo] = useAtom(pomodoroAtom) const [_, setPomo] = useAtom(pomodoroAtom)
const [modalConfig, setModalConfig] = useState<{ const [modalConfig, setModalConfig] = useState<{
@@ -126,13 +110,7 @@ export default function DailyOverview({
<h3 className="font-semibold">Daily Tasks</h3> <h3 className="font-semibold">Daily Tasks</h3>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary"> <CompletionCountBadge type="tasks" />
{`${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>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -149,7 +127,7 @@ export default function DailyOverview({
</Button> </Button>
</div> </div>
</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 {dailyTasks
.sort((a, b) => { .sort((a, b) => {
// First by completion status // First by completion status
@@ -177,7 +155,7 @@ export default function DailyOverview({
const bTarget = b.targetCompletions || 1; const bTarget = b.targetCompletions || 1;
return bTarget - aTarget; return bTarget - aTarget;
}) })
.slice(0, expandedTasks ? undefined : 5) .slice(0, browserSettings.expandedTasks ? undefined : 5)
.map((habit) => { .map((habit) => {
const completionsToday = habit.completions.filter(completion => 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 })) isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
@@ -279,10 +257,10 @@ export default function DailyOverview({
</ul> </ul>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <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" className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
> >
{expandedTasks ? ( {browserSettings.expandedTasks ? (
<> <>
Show less Show less
<ChevronUp className="h-3 w-3" /> <ChevronUp className="h-3 w-3" />
@@ -337,13 +315,7 @@ export default function DailyOverview({
<h3 className="font-semibold">Daily Habits</h3> <h3 className="font-semibold">Daily Habits</h3>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary"> <CompletionCountBadge type="habits" />
{`${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>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -360,7 +332,7 @@ export default function DailyOverview({
</Button> </Button>
</div> </div>
</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 {dailyHabits
.sort((a, b) => { .sort((a, b) => {
// First by completion status // First by completion status
@@ -388,7 +360,7 @@ export default function DailyOverview({
const bTarget = b.targetCompletions || 1; const bTarget = b.targetCompletions || 1;
return bTarget - aTarget; return bTarget - aTarget;
}) })
.slice(0, expandedHabits ? undefined : 5) .slice(0, browserSettings.expandedHabits ? undefined : 5)
.map((habit) => { .map((habit) => {
const completionsToday = habit.completions.filter(completion => 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 })) isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
@@ -490,10 +462,10 @@ export default function DailyOverview({
</ul> </ul>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <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" className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
> >
{expandedHabits ? ( {browserSettings.expandedHabits ? (
<> <>
Show less Show less
<ChevronUp className="h-3 w-3" /> <ChevronUp className="h-3 w-3" />
@@ -525,7 +497,7 @@ export default function DailyOverview({
</Badge> </Badge>
</div> </div>
<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 ? ( {sortedWishlistItems.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-4"> <div className="text-center text-muted-foreground text-sm py-4">
No wishlist items yet. Add some goals to work towards! No wishlist items yet. Add some goals to work towards!
@@ -533,7 +505,7 @@ export default function DailyOverview({
) : ( ) : (
<> <>
{sortedWishlistItems {sortedWishlistItems
.slice(0, expandedWishlist ? undefined : 5) .slice(0, browserSettings.expandedWishlist ? undefined : 5)
.map((item) => { .map((item) => {
const isRedeemable = item.coinCost <= coinBalance const isRedeemable = item.coinCost <= coinBalance
return ( return (
@@ -587,10 +559,10 @@ export default function DailyOverview({
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <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" className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
> >
{expandedWishlist ? ( {browserSettings.expandedWishlist ? (
<> <>
Show less Show less
<ChevronUp className="h-3 w-3" /> <ChevronUp className="h-3 w-3" />

View File

@@ -3,7 +3,7 @@
import { useState, useMemo, useCallback } from 'react' import { useState, useMemo, useCallback } from 'react'
import { Calendar } from '@/components/ui/calendar' import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 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 { Button } from '@/components/ui/button'
import { Check, Circle, CircleCheck } from 'lucide-react' import { Check, Circle, CircleCheck } from 'lucide-react'
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils' import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
@@ -25,7 +25,8 @@ export default function HabitCalendar() {
} }
}, [completePastHabit]) }, [completePastHabit])
const [settings] = useAtom(settingsAtom) 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 [habitsData] = useAtom(habitsAtom)
const [hasTasks] = useAtom(hasTasksAtom) const [hasTasks] = useAtom(hasTasksAtom)
const habits = habitsData.habits const habits = habitsData.habits
@@ -50,8 +51,8 @@ export default function HabitCalendar() {
<CardContent> <CardContent>
<Calendar <Calendar
mode="single" mode="single"
selected={selectedDate.toJSDate()} selected={selectedDateTime.toJSDate()}
onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))} onSelect={(e) => e && setSelectedDateTime(DateTime.fromJSDate(e))}
weekStartsOn={settings.system.weekStartDay} weekStartsOn={settings.system.weekStartDay}
className="rounded-md border" className="rounded-md border"
modifiers={{ modifiers={{
@@ -71,36 +72,31 @@ export default function HabitCalendar() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{selectedDate ? ( {selectedDateTime ? (
<>{d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</> <>{d2s({ dateTime: selectedDateTime, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
) : ( ) : (
'Select a date' 'Select a date'
)} )}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{selectedDate && ( {selectedDateTime && (
<div className="space-y-8"> <div className="space-y-8">
{hasTasks && ( {hasTasks && (
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Tasks</h3> <h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Tasks</h3>
<CompletionCountBadge <CompletionCountBadge type="tasks" date={selectedDate.toString()} />
habits={habits}
selectedDate={selectedDate}
timezone={settings.system.timezone}
type="tasks"
/>
</div> </div>
<ul className="space-y-3"> <ul className="space-y-3">
{habits {habits
.filter(habit => habit.isTask && isHabitDue({ .filter(habit => habit.isTask && isHabitDue({
habit, habit,
timezone: settings.system.timezone, timezone: settings.system.timezone,
date: selectedDate date: selectedDateTime
})) }))
.map((habit) => { .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) const isCompleted = completions >= (habit.targetCompletions || 1)
return ( 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"> <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> </span>
)} )}
<button <button
onClick={() => handleCompletePastHabit(habit, selectedDate)} onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted} disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100" className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
> >
@@ -149,22 +145,17 @@ export default function HabitCalendar() {
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Habits</h3> <h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Habits</h3>
<CompletionCountBadge <CompletionCountBadge type="habits" date={selectedDate.toString()} />
habits={habits}
selectedDate={selectedDate}
timezone={settings.system.timezone}
type="habits"
/>
</div> </div>
<ul className="space-y-3"> <ul className="space-y-3">
{habits {habits
.filter(habit => !habit.isTask && !habit.archived && isHabitDue({ .filter(habit => !habit.isTask && isHabitDue({
habit, habit,
timezone: settings.system.timezone, timezone: settings.system.timezone,
date: selectedDate date: selectedDateTime
})) }))
.map((habit) => { .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) const isCompleted = completions >= (habit.targetCompletions || 1)
return ( 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"> <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> </span>
)} )}
<button <button
onClick={() => handleCompletePastHabit(habit, selectedDate)} onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted} disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100" className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
> >

View File

@@ -56,12 +56,12 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
<> <>
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */} <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" : ""}`}> <nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
<div className="flex justify-around divide-x divide-gray-300/60 dark:divide-gray-600/60"> <div className="grid grid-cols-5 w-full">
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => ( {[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
<Link <Link
key={item.label} key={item.label}
href={item.href} href={item.href}
className="flex flex-col items-center py-2 px-4 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 flex-1" 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" /> <item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span> <span className="text-xs mt-1">{item.label}</span>

View File

@@ -32,7 +32,7 @@ export function ViewToggle({
// Calculate due tasks count // Calculate due tasks count
const dueTasksCount = habits.habits.filter(habit => 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 ).length
return ( return (

View File

@@ -7,6 +7,7 @@ import {
Habit, Habit,
ViewType, ViewType,
getDefaultUsersData, getDefaultUsersData,
CompletionCache,
} from "./types"; } from "./types";
import { import {
getTodayInTimezone, getTodayInTimezone,
@@ -18,16 +19,26 @@ import {
calculateCoinsSpentToday, calculateCoinsSpentToday,
calculateTransactionsToday, calculateTransactionsToday,
getCompletionsForToday, getCompletionsForToday,
getISODate getISODate,
isHabitDueToday,
getNow,
isHabitDue
} from "@/lib/utils"; } from "@/lib/utils";
import { atomWithStorage } from "jotai/utils"; import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon";
export interface BrowserSettings { export interface BrowserSettings {
viewType: ViewType viewType: ViewType
expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
} }
export const browserSettingsAtom = atomWithStorage('browserSettings', { export const browserSettingsAtom = atomWithStorage('browserSettings', {
viewType: 'habits' viewType: 'habits',
expandedHabits: false,
expandedTasks: false,
expandedWishlist: false
} as BrowserSettings) } as BrowserSettings)
export const usersAtom = atom(getDefaultUsersData()) export const usersAtom = atom(getDefaultUsersData())
@@ -92,36 +103,49 @@ export const pomodoroAtom = atom<PomodoroAtom>({
export const userSelectAtom = atom<boolean>(false) export const userSelectAtom = atom<boolean>(false)
// Derived atom for *fully* completed habits by date, respecting target completions // Derived atom for completion cache
export const completedHabitsMapAtom = atom((get) => { export const completionCacheAtom = atom((get) => {
const habits = get(habitsAtom).habits const habits = get(habitsAtom).habits;
const timezone = get(settingsAtom).system.timezone const timezone = get(settingsAtom).system.timezone;
const cache: CompletionCache = {};
const map = new Map<string, Habit[]>()
habits.forEach(habit => { habits.forEach(habit => {
// Group completions by date habit.completions.forEach(utcTimestamp => {
const completionsByDate = new Map<string, number>() const localDate = t2d({ timestamp: utcTimestamp, timezone })
.toFormat('yyyy-MM-dd');
habit.completions.forEach(completion => {
const dateKey = getISODate({ dateTime: t2d({ timestamp: completion, timezone }), timezone }) if (!cache[localDate]) {
completionsByDate.set(dateKey, (completionsByDate.get(dateKey) || 0) + 1) cache[localDate] = {};
})
// 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)
} }
})
}) 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) => { export const pomodoroTodayCompletionsAtom = atom((get) => {
@@ -145,3 +169,22 @@ export const hasTasksAtom = atom((get) => {
const habits = get(habitsAtom) const habits = get(habitsAtom)
return habits.habits.some(habit => habit.isTask === true) 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));
});

View File

@@ -164,6 +164,12 @@ export interface Settings {
profile: ProfileSettings; 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 type ViewType = 'habits' | 'tasks'
export interface JotaiHydrateInitialValues { export interface JotaiHydrateInitialValues {

View File

@@ -231,11 +231,18 @@ export function isHabitDue({
timezone: string timezone: string
date: DateTime date: DateTime
}): boolean { }): boolean {
// handle task
if (habit.isTask) { if (habit.isTask) {
// For tasks, frequency is stored as a UTC ISO timestamp // For tasks, frequency is stored as a UTC ISO timestamp
const taskDueDate = t2d({ timestamp: habit.frequency, timezone }) const taskDueDate = t2d({ timestamp: habit.frequency, timezone })
return isSameDate(taskDueDate, date); return isSameDate(taskDueDate, date);
} }
// handle habit
if (habit.archived) {
return false
}
const startOfDay = date.setZone(timezone).startOf('day') const startOfDay = date.setZone(timezone).startOf('day')
const endOfDay = date.setZone(timezone).endOf('day') const endOfDay = date.setZone(timezone).endOf('day')

View File

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