Skip to content

Commit 3b33719

Browse files
committed
fix completed habits map
1 parent 9d804db commit 3b33719

12 files changed

+130
-20
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Version 0.1.24
4+
5+
### Fixed
6+
7+
- completed habits atom should not store partially completed habits (#46)
8+
39
## Version 0.1.23
410

511
### Added

app/actions/data.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
8787
return saveData('habits', data)
8888
}
8989

90+
9091
// Coins specific functions
9192
export async function loadCoinsData(): Promise<CoinsData> {
9293
try {
@@ -172,7 +173,7 @@ export async function removeCoins(
172173
export async function uploadAvatar(formData: FormData) {
173174
const file = formData.get('avatar') as File
174175
if (!file) throw new Error('No file provided')
175-
176+
176177
if (file.size > 5 * 1024 * 1024) { // 5MB
177178
throw new Error('File size must be less than 5MB')
178179
}

app/calendar/page.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import Layout from '@/components/Layout'
22
import HabitCalendar from '@/components/HabitCalendar'
3+
import { ViewToggle } from '@/components/ViewToggle'
34

45
export default function CalendarPage() {
56
return (
6-
<HabitCalendar />
7+
<div className="flex flex-col gap-4">
8+
<div className="flex justify-end">
9+
{/* <ViewToggle /> */}
10+
</div>
11+
<HabitCalendar />
12+
</div>
713
)
814
}
915

app/habits/page.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import Layout from '@/components/Layout'
22
import HabitList from '@/components/HabitList'
3+
import { ViewToggle } from '@/components/ViewToggle'
34

45
export default function HabitsPage() {
56
return (
6-
<HabitList />
7+
<div className="flex flex-col gap-4">
8+
<div className="flex justify-end">
9+
{/* <ViewToggle /> */}
10+
</div>
11+
<HabitList />
12+
</div>
713
)
814
}
915

components/DailyOverview.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
99
import Link from 'next/link'
1010
import { useState, useEffect } from 'react'
1111
import { useAtom } from 'jotai'
12-
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms'
12+
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, transientSettingsAtom } from '@/lib/atoms'
1313
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
1414
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
1515
import { Badge } from '@/components/ui/badge'
@@ -74,11 +74,11 @@ export default function DailyOverview({
7474
<div className="flex items-center justify-between mb-2">
7575
<h3 className="font-semibold">Daily Habits</h3>
7676
<Badge variant="secondary">
77-
{dailyHabits.filter(habit => {
77+
{`${dailyHabits.filter(habit => {
7878
const completions = (completedHabitsMap.get(today) || [])
7979
.filter(h => h.id === habit.id).length;
8080
return completions >= (habit.targetCompletions || 1);
81-
}).length}/{dailyHabits.length} Completed
81+
}).length}/${dailyHabits.length} Completed`}
8282
</Badge>
8383
</div>
8484
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>

components/Dashboard.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import DailyOverview from './DailyOverview'
66
import HabitStreak from './HabitStreak'
77
import CoinBalance from './CoinBalance'
88
import { useHabits } from '@/hooks/useHabits'
9+
import { ViewToggle } from './ViewToggle'
910

1011
export default function Dashboard() {
1112
const [habitsData] = useAtom(habitsAtom)
@@ -18,7 +19,10 @@ export default function Dashboard() {
1819

1920
return (
2021
<div className="container mx-auto px-4 py-8">
21-
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
22+
<div className="flex justify-between items-center mb-6">
23+
<h1 className="text-3xl font-bold">Dashboard</h1>
24+
{/* <ViewToggle /> */}
25+
</div>
2226
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
2327
<CoinBalance coinBalance={coinBalance} />
2428
<HabitStreak habits={habits} />

components/HabitCalendar.tsx

+6-7
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
66
import { Badge } from '@/components/ui/badge'
77
import { Button } from '@/components/ui/button'
88
import { Check, Circle, CircleCheck } from 'lucide-react'
9-
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate } from '@/lib/utils'
9+
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
1010
import { useAtom } from 'jotai'
1111
import { useHabits } from '@/hooks/useHabits'
1212
import { habitsAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms'
@@ -87,9 +87,8 @@ export default function HabitCalendar() {
8787
date: selectedDate
8888
}))
8989
.map((habit) => {
90-
const habitsForDate = completedHabitsMap.get(getISODate({ dateTime: selectedDate, timezone: settings.system.timezone })) || []
91-
const completionsToday = habitsForDate.filter((h: Habit) => h.id === habit.id).length
92-
const isCompleted = completionsToday >= (habit.targetCompletions || 1)
90+
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone })
91+
const isCompleted = completions >= (habit.targetCompletions || 1)
9392
return (
9493
<li key={habit.id} className="flex items-center justify-between gap-2">
9594
<span>
@@ -99,7 +98,7 @@ export default function HabitCalendar() {
9998
<div className="flex items-center gap-2">
10099
{habit.targetCompletions && (
101100
<span className="text-sm text-muted-foreground">
102-
{completionsToday}/{habit.targetCompletions}
101+
{completions}/{habit.targetCompletions}
103102
</span>
104103
)}
105104
<button
@@ -116,8 +115,8 @@ export default function HabitCalendar() {
116115
className="absolute h-4 w-4 rounded-full overflow-hidden"
117116
style={{
118117
background: `conic-gradient(
119-
currentColor ${(completionsToday / (habit.targetCompletions ?? 1)) * 360}deg,
120-
transparent ${(completionsToday / (habit.targetCompletions ?? 1)) * 360}deg 360deg
118+
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
119+
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
121120
)`,
122121
mask: 'radial-gradient(transparent 50%, black 51%)',
123122
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'

components/ViewToggle.tsx

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
'use client'
2+
3+
import { cn } from '@/lib/utils'
4+
import { useAtom } from 'jotai'
5+
import { CheckSquare, ListChecks } from 'lucide-react'
6+
import { transientSettingsAtom } from '@/lib/atoms'
7+
import type { ViewType } from '@/lib/types'
8+
9+
interface ViewToggleProps {
10+
defaultView?: ViewType
11+
className?: string
12+
}
13+
14+
export function ViewToggle({
15+
defaultView = 'habits',
16+
className
17+
}: ViewToggleProps) {
18+
const [transientSettings, setTransientSettings] = useAtom(transientSettingsAtom)
19+
20+
const handleViewChange = (checked: boolean) => {
21+
const newView = checked ? 'tasks' : 'habits'
22+
setTransientSettings({
23+
...transientSettings,
24+
viewType: newView,
25+
})
26+
}
27+
28+
return (
29+
<div className={cn('inline-flex rounded-full bg-muted/50', className)}>
30+
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5">
31+
<button
32+
onClick={() => handleViewChange(false)}
33+
className={cn(
34+
'relative z-10 rounded-full px-3 py-1 text-xs font-medium transition-colors flex items-center gap-1',
35+
transientSettings.viewType === 'habits' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
36+
)}
37+
>
38+
<ListChecks className="h-3 w-3" />
39+
<span className="hidden sm:inline">Habits</span>
40+
</button>
41+
<button
42+
onClick={() => handleViewChange(true)}
43+
className={cn(
44+
'relative z-10 rounded-full px-3 py-1 text-xs font-medium transition-colors flex items-center gap-1',
45+
transientSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
46+
)}
47+
>
48+
<CheckSquare className="h-3 w-3" />
49+
<span className="hidden sm:inline">Tasks</span>
50+
</button>
51+
<div
52+
className={cn(
53+
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
54+
transientSettings.viewType === 'habits' ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
55+
)}
56+
/>
57+
</div>
58+
</div>
59+
)
60+
}

hooks/useTasks.tsx

Whitespace-only changes.

lib/atoms.ts

+28-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
getDefaultHabitsData,
55
getDefaultCoinsData,
66
getDefaultWishlistData,
7-
Habit
7+
Habit,
8+
ViewType,
89
} from "./types";
910
import {
1011
getTodayInTimezone,
@@ -72,24 +73,38 @@ export const pomodoroAtom = atom<PomodoroAtom>({
7273
minimized: false,
7374
})
7475

75-
// Derived atom for today's completions of selected habit
76+
// Derived atom for *fully* completed habits by date, respecting target completions
7677
export const completedHabitsMapAtom = atom((get) => {
7778
const habits = get(habitsAtom).habits
7879
const timezone = get(settingsAtom).system.timezone
7980

8081
const map = new Map<string, Habit[]>()
82+
8183
habits.forEach(habit => {
84+
// Group completions by date
85+
const completionsByDate = new Map<string, number>()
86+
8287
habit.completions.forEach(completion => {
8388
const dateKey = getISODate({ dateTime: t2d({ timestamp: completion, timezone }), timezone })
84-
if (!map.has(dateKey)) {
85-
map.set(dateKey, [])
89+
completionsByDate.set(dateKey, (completionsByDate.get(dateKey) || 0) + 1)
90+
})
91+
92+
// Check if habit meets target completions for each date
93+
completionsByDate.forEach((count, dateKey) => {
94+
const target = habit.targetCompletions || 1
95+
if (count >= target) {
96+
if (!map.has(dateKey)) {
97+
map.set(dateKey, [])
98+
}
99+
map.get(dateKey)!.push(habit)
86100
}
87-
map.get(dateKey)!.push(habit)
88101
})
89102
})
103+
90104
return map
91105
})
92106

107+
93108
export const pomodoroTodayCompletionsAtom = atom((get) => {
94109
const pomo = get(pomodoroAtom)
95110
const habits = get(habitsAtom)
@@ -105,3 +120,11 @@ export const pomodoroTodayCompletionsAtom = atom((get) => {
105120
timezone: settings.system.timezone
106121
})
107122
})
123+
124+
export interface TransientSettings {
125+
viewType: ViewType
126+
}
127+
128+
export const transientSettingsAtom = atom<TransientSettings>({
129+
viewType: 'habits'
130+
})

lib/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type Habit = {
88
completions: string[] // Array of UTC ISO date strings
99
}
1010

11+
1112
export type Freq = 'daily' | 'weekly' | 'monthly' | 'yearly'
1213

1314
export type WishlistItemType = {
@@ -33,6 +34,7 @@ export interface HabitsData {
3334
habits: Habit[];
3435
}
3536

37+
3638
export interface CoinsData {
3739
balance: number;
3840
transactions: CoinTransaction[];
@@ -49,6 +51,7 @@ export const getDefaultHabitsData = (): HabitsData => ({
4951
habits: []
5052
});
5153

54+
5255
export const getDefaultCoinsData = (): CoinsData => ({
5356
balance: 0,
5457
transactions: []
@@ -103,6 +106,8 @@ export interface Settings {
103106
profile: ProfileSettings;
104107
}
105108

109+
export type ViewType = 'habits' | 'tasks'
110+
106111
export interface JotaiHydrateInitialValues {
107112
settings: Settings;
108113
coins: CoinsData;

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "habittrove",
3-
"version": "0.1.23",
3+
"version": "0.1.24",
44
"private": true,
55
"scripts": {
66
"dev": "next dev --turbopack",

0 commit comments

Comments
 (0)