mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
fix completed habits map
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -172,7 +173,7 @@ export async function removeCoins(
|
|||||||
export async function uploadAvatar(formData: FormData) {
|
export async function uploadAvatar(formData: FormData) {
|
||||||
const file = formData.get('avatar') as File
|
const file = formData.get('avatar') as File
|
||||||
if (!file) throw new Error('No file provided')
|
if (!file) throw new Error('No file provided')
|
||||||
|
|
||||||
if (file.size > 5 * 1024 * 1024) { // 5MB
|
if (file.size > 5 * 1024 * 1024) { // 5MB
|
||||||
throw new Error('File size must be less than 5MB')
|
throw new Error('File size must be less than 5MB')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
<HabitCalendar />
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{/* <ViewToggle /> */}
|
||||||
|
</div>
|
||||||
|
<HabitCalendar />
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
<HabitList />
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{/* <ViewToggle /> */}
|
||||||
|
</div>
|
||||||
|
<HabitList />
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`}>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
60
components/ViewToggle.tsx
Normal 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
0
hooks/useTasks.tsx
Normal file
33
lib/atoms.ts
33
lib/atoms.ts
@@ -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 })
|
||||||
if (!map.has(dateKey)) {
|
completionsByDate.set(dateKey, (completionsByDate.get(dateKey) || 0) + 1)
|
||||||
map.set(dateKey, [])
|
})
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
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'
|
||||||
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user