fix completed habits map

This commit is contained in:
dohsimpson
2025-01-21 22:28:51 -05:00
parent 9d804dba1e
commit 3b33719e1a
12 changed files with 130 additions and 20 deletions

View File

@@ -1,5 +1,11 @@
# Changelog # Changelog
## Version 0.1.24
### Fixed
- completed habits atom should not store partially completed habits (#46)
## Version 0.1.23 ## Version 0.1.23
### Added ### Added

View File

@@ -87,6 +87,7 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
return saveData('habits', data) return saveData('habits', data)
} }
// Coins specific functions // Coins specific functions
export async function loadCoinsData(): Promise<CoinsData> { export async function loadCoinsData(): Promise<CoinsData> {
try { try {

View File

@@ -1,9 +1,15 @@
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'
export default function CalendarPage() { export default function CalendarPage() {
return ( return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
{/* <ViewToggle /> */}
</div>
<HabitCalendar /> <HabitCalendar />
</div>
) )
} }

View File

@@ -1,9 +1,15 @@
import Layout from '@/components/Layout' import Layout from '@/components/Layout'
import HabitList from '@/components/HabitList' import HabitList from '@/components/HabitList'
import { ViewToggle } from '@/components/ViewToggle'
export default function HabitsPage() { export default function HabitsPage() {
return ( return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
{/* <ViewToggle /> */}
</div>
<HabitList /> <HabitList />
</div>
) )
} }

View File

@@ -9,7 +9,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 } from '@/lib/atoms' import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, transientSettingsAtom } 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'
@@ -74,11 +74,11 @@ export default function DailyOverview({
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Habits</h3> <h3 className="font-semibold">Daily Habits</h3>
<Badge variant="secondary"> <Badge variant="secondary">
{dailyHabits.filter(habit => { {`${dailyHabits.filter(habit => {
const completions = (completedHabitsMap.get(today) || []) const completions = (completedHabitsMap.get(today) || [])
.filter(h => h.id === habit.id).length; .filter(h => h.id === habit.id).length;
return completions >= (habit.targetCompletions || 1); return completions >= (habit.targetCompletions || 1);
}).length}/{dailyHabits.length} Completed }).length}/${dailyHabits.length} Completed`}
</Badge> </Badge>
</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 ${expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>

View File

@@ -6,6 +6,7 @@ import DailyOverview from './DailyOverview'
import HabitStreak from './HabitStreak' import HabitStreak from './HabitStreak'
import CoinBalance from './CoinBalance' import CoinBalance from './CoinBalance'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { ViewToggle } from './ViewToggle'
export default function Dashboard() { export default function Dashboard() {
const [habitsData] = useAtom(habitsAtom) const [habitsData] = useAtom(habitsAtom)
@@ -18,7 +19,10 @@ export default function Dashboard() {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Dashboard</h1> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
{/* <ViewToggle /> */}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<CoinBalance coinBalance={coinBalance} /> <CoinBalance coinBalance={coinBalance} />
<HabitStreak habits={habits} /> <HabitStreak habits={habits} />

View File

@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
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 } from '@/lib/utils' import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms' import { habitsAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms'
@@ -87,9 +87,8 @@ export default function HabitCalendar() {
date: selectedDate date: selectedDate
})) }))
.map((habit) => { .map((habit) => {
const habitsForDate = completedHabitsMap.get(getISODate({ dateTime: selectedDate, timezone: settings.system.timezone })) || [] const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone })
const completionsToday = habitsForDate.filter((h: Habit) => h.id === habit.id).length const isCompleted = completions >= (habit.targetCompletions || 1)
const isCompleted = completionsToday >= (habit.targetCompletions || 1)
return ( return (
<li key={habit.id} className="flex items-center justify-between gap-2"> <li key={habit.id} className="flex items-center justify-between gap-2">
<span> <span>
@@ -99,7 +98,7 @@ export default function HabitCalendar() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{habit.targetCompletions && ( {habit.targetCompletions && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{completionsToday}/{habit.targetCompletions} {completions}/{habit.targetCompletions}
</span> </span>
)} )}
<button <button
@@ -116,8 +115,8 @@ export default function HabitCalendar() {
className="absolute h-4 w-4 rounded-full overflow-hidden" className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{ style={{
background: `conic-gradient( background: `conic-gradient(
currentColor ${(completionsToday / (habit.targetCompletions ?? 1)) * 360}deg, currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
transparent ${(completionsToday / (habit.targetCompletions ?? 1)) * 360}deg 360deg transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
)`, )`,
mask: 'radial-gradient(transparent 50%, black 51%)', mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)' WebkitMask: 'radial-gradient(transparent 50%, black 51%)'

60
components/ViewToggle.tsx Normal file
View File

@@ -0,0 +1,60 @@
'use client'
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { CheckSquare, ListChecks } from 'lucide-react'
import { transientSettingsAtom } from '@/lib/atoms'
import type { ViewType } from '@/lib/types'
interface ViewToggleProps {
defaultView?: ViewType
className?: string
}
export function ViewToggle({
defaultView = 'habits',
className
}: ViewToggleProps) {
const [transientSettings, setTransientSettings] = useAtom(transientSettingsAtom)
const handleViewChange = (checked: boolean) => {
const newView = checked ? 'tasks' : 'habits'
setTransientSettings({
...transientSettings,
viewType: newView,
})
}
return (
<div className={cn('inline-flex rounded-full bg-muted/50', className)}>
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5">
<button
onClick={() => handleViewChange(false)}
className={cn(
'relative z-10 rounded-full px-3 py-1 text-xs font-medium transition-colors flex items-center gap-1',
transientSettings.viewType === 'habits' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
<ListChecks className="h-3 w-3" />
<span className="hidden sm:inline">Habits</span>
</button>
<button
onClick={() => handleViewChange(true)}
className={cn(
'relative z-10 rounded-full px-3 py-1 text-xs font-medium transition-colors flex items-center gap-1',
transientSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
<CheckSquare className="h-3 w-3" />
<span className="hidden sm:inline">Tasks</span>
</button>
<div
className={cn(
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
transientSettings.viewType === 'habits' ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
)}
/>
</div>
</div>
)
}

0
hooks/useTasks.tsx Normal file
View File

View File

@@ -4,7 +4,8 @@ import {
getDefaultHabitsData, getDefaultHabitsData,
getDefaultCoinsData, getDefaultCoinsData,
getDefaultWishlistData, getDefaultWishlistData,
Habit Habit,
ViewType,
} from "./types"; } from "./types";
import { import {
getTodayInTimezone, getTodayInTimezone,
@@ -72,24 +73,38 @@ export const pomodoroAtom = atom<PomodoroAtom>({
minimized: false, minimized: false,
}) })
// Derived atom for today's completions of selected habit // Derived atom for *fully* completed habits by date, respecting target completions
export const completedHabitsMapAtom = atom((get) => { export const completedHabitsMapAtom = 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 map = new Map<string, Habit[]>() const map = new Map<string, Habit[]>()
habits.forEach(habit => { habits.forEach(habit => {
// Group completions by date
const completionsByDate = new Map<string, number>()
habit.completions.forEach(completion => { habit.completions.forEach(completion => {
const dateKey = getISODate({ dateTime: t2d({ timestamp: completion, timezone }), timezone }) 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)) { if (!map.has(dateKey)) {
map.set(dateKey, []) map.set(dateKey, [])
} }
map.get(dateKey)!.push(habit) map.get(dateKey)!.push(habit)
}
}) })
}) })
return map return map
}) })
export const pomodoroTodayCompletionsAtom = atom((get) => { export const pomodoroTodayCompletionsAtom = atom((get) => {
const pomo = get(pomodoroAtom) const pomo = get(pomodoroAtom)
const habits = get(habitsAtom) const habits = get(habitsAtom)
@@ -105,3 +120,11 @@ export const pomodoroTodayCompletionsAtom = atom((get) => {
timezone: settings.system.timezone timezone: settings.system.timezone
}) })
}) })
export interface TransientSettings {
viewType: ViewType
}
export const transientSettingsAtom = atom<TransientSettings>({
viewType: 'habits'
})

View File

@@ -8,6 +8,7 @@ export type Habit = {
completions: string[] // Array of UTC ISO date strings completions: string[] // Array of UTC ISO date strings
} }
export type Freq = 'daily' | 'weekly' | 'monthly' | 'yearly' export type Freq = 'daily' | 'weekly' | 'monthly' | 'yearly'
export type WishlistItemType = { export type WishlistItemType = {
@@ -33,6 +34,7 @@ export interface HabitsData {
habits: Habit[]; habits: Habit[];
} }
export interface CoinsData { export interface CoinsData {
balance: number; balance: number;
transactions: CoinTransaction[]; transactions: CoinTransaction[];
@@ -49,6 +51,7 @@ export const getDefaultHabitsData = (): HabitsData => ({
habits: [] habits: []
}); });
export const getDefaultCoinsData = (): CoinsData => ({ export const getDefaultCoinsData = (): CoinsData => ({
balance: 0, balance: 0,
transactions: [] transactions: []
@@ -103,6 +106,8 @@ export interface Settings {
profile: ProfileSettings; profile: ProfileSettings;
} }
export type ViewType = 'habits' | 'tasks'
export interface JotaiHydrateInitialValues { export interface JotaiHydrateInitialValues {
settings: Settings; settings: Settings;
coins: CoinsData; coins: CoinsData;

View File

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