mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dea2b30c3b |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
103
lib/atoms.ts
103
lib/atoms.ts
@@ -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));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user