Added PWA support (#40)

This commit is contained in:
Doh
2025-01-15 20:07:23 -05:00
committed by GitHub
parent 1bb968b7c1
commit 71b9d1b408
32 changed files with 1274 additions and 312 deletions

View File

@@ -0,0 +1,19 @@
'use client'
import { ReactNode } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom } from '@/lib/atoms'
import PomodoroTimer from './PomodoroTimer'
export default function ClientWrapper({ children }: { children: ReactNode }) {
const [pomo] = useAtom(pomodoroAtom)
return (
<>
{children}
{pomo.show && (
<PomodoroTimer />
)}
</>
)
}

View File

@@ -3,11 +3,12 @@ import { Coins } from 'lucide-react'
import { FormattedNumber } from '@/components/FormattedNumber'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import dynamic from 'next/dynamic'
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
const [settings] = useAtom(settingsAtom)
const { coinsEarnedToday } = useCoins()
return (
<Card>
<CardHeader>
@@ -17,19 +18,14 @@ export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
<div className="flex items-center justify-center">
<Coins className="h-12 w-12 text-yellow-400 mr-4" />
<div className="flex flex-col">
<span className="text-4xl font-bold">
<FormattedNumber amount={coinBalance} settings={settings} />
</span>
{coinsEarnedToday > 0 && (
<div className="flex items-center gap-1 mt-1">
<span className="text-md text-green-600 dark:text-green-400 font-medium">
+<FormattedNumber amount={coinsEarnedToday} settings={settings} />
</span>
<span className="text-md text-muted-foreground">
today
</span>
<div className="flex flex-col">
<span className="text-4xl font-bold">
<FormattedNumber amount={coinBalance} settings={settings} />
</span>
<div className="flex items-center gap-1">
<TodayEarnedCoins longFormat={true} />
</div>
)}
</div>
</div>
</div>
</CardContent>

View File

@@ -1,9 +1,15 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp } from 'lucide-react'
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { pomodoroAtom, settingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletedHabitsForDate, getCompletionsForDate } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -40,245 +46,181 @@ export default function DailyOverview({
setDailyHabits(filteredHabits)
}, [habits])
// Get all wishlist items sorted by coin cost
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
const sortedWishlistItems = wishlistItems
.sort((a, b) => a.coinCost - b.coinCost)
.sort((a, b) => {
const aRedeemable = a.coinCost <= coinBalance
const bRedeemable = b.coinCost <= coinBalance
// Non-redeemable items first
if (aRedeemable !== bRedeemable) {
return aRedeemable ? 1 : -1
}
// Then sort by coin cost (lower cost first)
return a.coinCost - b.coinCost
})
const [expandedHabits, setExpandedHabits] = useState(false)
const [expandedWishlist, setExpandedWishlist] = useState(false)
const [_, setPomo] = useAtom(pomodoroAtom)
return (
<Card>
<CardHeader>
<CardTitle>Today's Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Habits</h3>
<Badge variant="secondary">
{dailyHabits.filter(habit => {
const completions = getCompletionsForDate({
habit,
date: today,
timezone: settings.system.timezone
});
return completions >= (habit.targetCompletions || 1);
}).length}/{dailyHabits.length} Completed
</Badge>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-[500px] opacity-100' : 'max-h-[200px] opacity-100'} overflow-hidden`}>
{dailyHabits
.sort((a, b) => {
// First by completion status
const aCompleted = todayCompletions.includes(a);
const bCompleted = todayCompletions.includes(b);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then by frequency (daily first)
const aFreq = getHabitFreq(a);
const bFreq = getHabitFreq(b);
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
}
// Then by coin reward (higher first)
if (a.coinReward !== b.coinReward) {
return b.coinReward - a.coinReward;
}
// Finally by target completions (higher first)
const aTarget = a.targetCompletions || 1;
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, expandedHabits ? undefined : 5)
.map((habit) => {
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 }))
).length
const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target
return (
<li
key={habit.id}
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
>
<span className="flex items-center gap-2">
<button
onClick={(e) => {
e.preventDefault();
if (isCompleted) {
undoComplete(habit);
} else {
completeHabit(habit);
}
}}
className="relative hover:opacity-70 transition-opacity w-4 h-4"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completionsToday / target) * 360}deg,
transparent ${(completionsToday / target) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
<span className={isCompleted ? 'line-through' : ''}>
<Linkify>
{habit.name}
</Linkify>
</span>
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
{habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target}
</span>
)}
{getHabitFreq(habit) !== 'daily' && (
<Badge variant="outline" className="text-xs">
{getHabitFreq(habit)}
</Badge>
)}
<span className="flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isCompleted
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isCompleted
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{habit.coinReward}
</span>
</span>
</span>
</li>
)
})}
</ul>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedHabits(!expandedHabits)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedHabits ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/habits"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Wishlist Goals</h3>
<Badge variant="secondary">
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
</Badge>
</div>
<>
<Card>
<CardHeader>
<CardTitle>Today's Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-[500px]' : 'max-h-[200px]'} overflow-hidden`}>
{sortedWishlistItems.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-4">
No wishlist items yet. Add some goals to work towards!
</div>
) : (
<>
{sortedWishlistItems
.slice(0, expandedWishlist ? undefined : 5)
.map((item) => {
const isRedeemable = item.coinCost <= coinBalance
return (
<Link
key={item.id}
href={`/wishlist?highlight=${item.id}`}
className={cn(
"block p-3 rounded-md hover:bg-secondary/30 transition-colors",
isRedeemable ? 'bg-green-500/10' : 'bg-secondary/20'
)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm">
<Linkify>{item.name}</Linkify>
</span>
<span className="text-xs flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isRedeemable
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isRedeemable
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{item.coinCost}
</span>
</span>
</div>
<Progress
value={(coinBalance / item.coinCost) * 100}
className={cn(
"h-2",
isRedeemable ? "bg-green-500/20" : ""
)}
/>
<p className="text-xs text-muted-foreground mt-2">
{isRedeemable
? "Ready to redeem!"
: `${item.coinCost - coinBalance} coins to go`
}
</p>
</Link>
)
})}
</>
)}
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Habits</h3>
<Badge variant="secondary">
{dailyHabits.filter(habit => {
const completions = getCompletionsForDate({
habit,
date: today,
timezone: settings.system.timezone
});
return completions >= (habit.targetCompletions || 1);
}).length}/{dailyHabits.length} Completed
</Badge>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{dailyHabits
.sort((a, b) => {
// First by completion status
const aCompleted = todayCompletions.includes(a);
const bCompleted = todayCompletions.includes(b);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then by frequency (daily first)
const aFreq = getHabitFreq(a);
const bFreq = getHabitFreq(b);
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
}
// Then by coin reward (higher first)
if (a.coinReward !== b.coinReward) {
return b.coinReward - a.coinReward;
}
// Finally by target completions (higher first)
const aTarget = a.targetCompletions || 1;
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, expandedHabits ? undefined : 5)
.map((habit) => {
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 }))
).length
const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target
return (
<li
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id}
>
<span className="flex items-center gap-2">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-none">
<button
onClick={(e) => {
e.preventDefault();
if (isCompleted) {
undoComplete(habit);
} else {
completeHabit(habit);
}
}}
className="relative hover:opacity-70 transition-opacity w-4 h-4"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completionsToday / target) * 360}deg,
transparent ${(completionsToday / target) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</ContextMenuTrigger>
<span className={isCompleted ? 'line-through' : ''}>
<Linkify>
{habit.name}
</Linkify>
</span>
<ContextMenuContent className="w-64">
<ContextMenuItem onClick={() => {
setPomo((prev) => ({
...prev,
show: true,
selectedHabitId: habit.id
}))
}}>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
{habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target}
</span>
)}
{getHabitFreq(habit) !== 'daily' && (
<Badge variant="outline" className="text-xs">
{getHabitFreq(habit)}
</Badge>
)}
<span className="flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isCompleted
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isCompleted
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{habit.coinReward}
</span>
</span>
</span>
</li>
)
})}
</ul>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedWishlist(!expandedWishlist)}
onClick={() => setExpandedHabits(!expandedHabits)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedWishlist ? (
{expandedHabits ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
@@ -291,7 +233,7 @@ export default function DailyOverview({
)}
</button>
<Link
href="/wishlist"
href="/habits"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
@@ -299,9 +241,105 @@ export default function DailyOverview({
</Link>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Wishlist Goals</h3>
<Badge variant="secondary">
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
</Badge>
</div>
<div>
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{sortedWishlistItems.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-4">
No wishlist items yet. Add some goals to work towards!
</div>
) : (
<>
{sortedWishlistItems
.slice(0, expandedWishlist ? undefined : 5)
.map((item) => {
const isRedeemable = item.coinCost <= coinBalance
return (
<Link
key={item.id}
href={`/wishlist?highlight=${item.id}`}
className={cn(
"block p-3 rounded-md hover:bg-secondary/30 transition-colors",
isRedeemable ? 'bg-green-500/10' : 'bg-secondary/20'
)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm">
<Linkify>{item.name}</Linkify>
</span>
<span className="text-xs flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isRedeemable
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isRedeemable
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{item.coinCost}
</span>
</span>
</div>
<Progress
value={(coinBalance / item.coinCost) * 100}
className={cn(
"h-2",
isRedeemable ? "bg-green-500/20" : ""
)}
/>
<p className="text-xs text-muted-foreground mt-2">
{isRedeemable
? "Ready to redeem!"
: `${item.coinCost - coinBalance} coins to go`
}
</p>
</Link>
)
})}
</>
)}
</div>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedWishlist(!expandedWishlist)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedWishlist ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/wishlist"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</>
)
}

View File

@@ -2,9 +2,9 @@
import { useAtom } from 'jotai'
import { wishlistAtom, habitsAtom, settingsAtom, coinsAtom } from '@/lib/atoms'
import CoinBalance from './CoinBalance'
import DailyOverview from './DailyOverview'
import HabitStreak from './HabitStreak'
import CoinBalance from './CoinBalance'
import { useHabits } from '@/hooks/useHabits'
export default function Dashboard() {

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { coinsAtom, settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Bell, Menu, Settings, User, Info, Coins } from 'lucide-react'
@@ -17,15 +17,18 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import AboutModal from './AboutModal'
import Link from 'next/link'
import dynamic from 'next/dynamic'
interface HeaderProps {
className?: string
}
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function Header({ className }: HeaderProps) {
const [showAbout, setShowAbout] = useState(false)
const [settings] = useAtom(settingsAtom)
const { balance, coinsEarnedToday } = useCoins()
const [coins] = useAtom(coinsAtom)
return (
<>
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
@@ -39,19 +42,13 @@ export default function Header({ className }: HeaderProps) {
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
<div className="flex items-baseline gap-1 sm:gap-2">
<FormattedNumber
amount={balance}
amount={coins.balance}
settings={settings}
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
/>
{coinsEarnedToday > 0 && (
<span className="text-sm bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-1 rounded-full border border-green-100 dark:border-green-800">
<FormattedNumber
amount={coinsEarnedToday}
settings={settings}
className="inline"
/>
</span>
)}
<div className="hidden sm:block">
<TodayEarnedCoins />
</div>
</div>
</Link>
<Button variant="ghost" size="icon" aria-label="Notifications">

View File

@@ -1,5 +1,5 @@
import ClientWrapper from './ClientWrapper'
import Header from './Header'
import LinkifyComponent from './linkify'
import Navigation from './Navigation'
export default function Layout({ children }: { children: React.ReactNode }) {
@@ -9,8 +9,10 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<div className="flex flex-1 overflow-hidden">
<Navigation viewPort='main' />
<div className="flex-1 flex flex-col">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900">
{children}
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
<ClientWrapper>
{children}
</ClientWrapper>
</main>
<Navigation viewPort='mobile' />
</div>

View File

@@ -0,0 +1,338 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Play, Pause, RotateCw, Minus, X, Clock, SkipForward } from 'lucide-react'
import { cn, getCompletionsForToday } from '@/lib/utils'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, habitsAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils'
import { useHabits } from '@/hooks/useHabits'
interface PomoConfig {
labels: string[]
duration: number
type: 'focus' | 'break'
}
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
focus: {
labels: [
'Stay Focused',
'You Got This',
'Keep Going',
'Crush It',
'Make It Happen',
'Stay Strong',
'Push Through',
'One Step at a Time',
'You Can Do It',
'Focus and Conquer'
],
duration: 25 * 60,
type: 'focus',
},
break: {
labels: [
'Take a Break',
'Relax and Recharge',
'Breathe Deeply',
'Stretch It Out',
'Refresh Yourself',
'You Deserve This',
'Recharge Your Energy',
'Step Away for a Bit',
'Clear Your Mind',
'Rest and Rejuvenate'
],
duration: 5 * 60,
type: 'break',
},
}
export default function PomodoroTimer() {
const [settings] = useAtom(settingsAtom)
const [pomo, setPomo] = useAtom(pomodoroAtom)
const { show, selectedHabitId, autoStart, minimized } = pomo
const [habitsData] = useAtom(habitsAtom)
const { completeHabit } = useHabits()
const selectedHabit = selectedHabitId ? habitsData.habits.find(habit => habit.id === selectedHabitId) : null
const [timeLeft, setTimeLeft] = useState(PomoConfigs.focus.duration)
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
const wakeLock = useRef<WakeLockSentinel | null>(null)
const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom)
const currentTimer = useRef<PomoConfig>(PomoConfigs.focus)
const [currentLabel, setCurrentLabel] = useState(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
// Handle wake lock
useEffect(() => {
const requestWakeLock = async () => {
try {
if (!('wakeLock' in navigator)) {
console.debug('Browser does not support wakelock')
return
}
if (wakeLock.current && !wakeLock.current.released) {
console.debug('Wake lock already in use')
return
}
if (state === 'started') {
// acquire wake lock
wakeLock.current = await navigator.wakeLock.request('screen')
return
}
} catch (err) {
console.error('Error requesting wake lock:', err)
}
}
const releaseWakeLock = async () => {
try {
if (wakeLock.current) {
await wakeLock.current.release()
wakeLock.current = null
}
} catch (err) {
console.error('Error releasing wake lock:', err)
}
}
const handleVisibilityChange = async () => {
if (document.visibilityState === 'hidden') {
await releaseWakeLock();
} else if (document.visibilityState === 'visible') {
// Always update indicator when tab becomes visible
if (state === 'started') {
await requestWakeLock();
}
}
};
if (state === 'started') {
document.addEventListener('visibilitychange', handleVisibilityChange);
requestWakeLock()
}
// return handles all other states
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseWakeLock()
}
}, [state])
// Timer logic
useEffect(() => {
let interval: ReturnType<typeof setInterval> | null = null
if (state === 'started') {
// Calculate the target end time based on current timeLeft
const targetEndTime = Date.now() + timeLeft * 1000
interval = setInterval(() => {
const remaining = Math.floor((targetEndTime - Date.now()) / 1000)
if (remaining <= 0) {
handleTimerEnd()
} else {
setTimeLeft(remaining)
}
}, 1000)
}
// return handles any other states
return () => {
if (interval) clearInterval(interval)
}
}, [state])
const playSound = useCallback(() => {
const audio = new Audio('/sounds/timer-end.wav')
audio.play().catch(error => {
console.error('Error playing sound:', error)
})
}, [])
const handleTimerEnd = async () => {
setState("stopped")
currentTimer.current = currentTimer.current.type === 'focus' ? PomoConfigs.break : PomoConfigs.focus
setTimeLeft(currentTimer.current.duration)
setCurrentLabel(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
// Play sound
playSound()
// update habits
if (selectedHabit) {
const ret = await completeHabit(selectedHabit)
if (ret) {
const updatedHabit = ret.updatedHabits.find(h => h.id === selectedHabit.id)
// The atom will automatically update with the new completions
}
}
}
const toggleTimer = () => {
setState(prev => prev === 'started' ? 'paused' : 'started')
}
const resetTimer = () => {
setState("stopped")
setTimeLeft(currentTimer.current.duration)
}
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${secs < 10 ? '0' : ''}${secs}`
}
const progress = (timeLeft / currentTimer.current.duration) * 100
if (!show) return null
return (
<div className="fixed bottom-20 right-4 lg:bottom-4 bg-background border rounded-lg shadow-lg">
{minimized ? (
// minimized version
<div
className="p-2 cursor-pointer relative overflow-hidden"
onClick={() => setPomo(prev => ({ ...prev, minimized: false }))}
>
<div className="flex items-center gap-2 font-bold">
<Clock className="h-4 w-4" />
<div className="text-sm">
{formatTime(timeLeft)}
</div>
</div>
{/* Progress bar as bottom border */}
<div
className="absolute bottom-0 left-0 h-0.5 bg-primary"
style={{ width: `${progress}%` }}
/>
</div>
) : (
// full version
<div className="flex flex-col items-center gap-4 p-4 relative">
<div className="absolute top-2 right-4 flex gap-2">
<button
onClick={() => setPomo(prev => ({ ...prev, minimized: true }))}
className="text-muted-foreground hover:text-foreground"
>
<Minus className="h-5 w-5" />
</button>
<button
// terminate the timer
onClick={() => setPomo(prev => ({ ...prev, show: false }))}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex flex-col items-center gap-4">
<div className="text-4xl font-bold">
{formatTime(timeLeft)}
</div>
<div className="text-sm text-center">
{selectedHabit && (
<div className="mb-2 flex justify-center">
<div className="flex items-center gap-2">
<div className={cn(
'w-2 h-2 rounded-full flex-none',
// order matters here
currentTimer.current.type === 'focus' && 'bg-green-500',
state === 'started' && 'animate-pulse',
state === 'paused' && 'bg-yellow-500',
state === 'stopped' && 'bg-red-500',
currentTimer.current.type === 'break' && 'bg-blue-500',
)} />
<div className="font-bold text-foreground">
{selectedHabit.name}
</div>
</div>
</div>
)}
<span>{currentTimer.current.type.charAt(0).toUpperCase() + currentTimer.current.type.slice(1)}: {currentLabel}</span>
{selectedHabit && (
<div className="flex justify-center gap-1 mt-2">
{(() => {
const total = selectedHabit.targetCompletions || 1
// Show up to 7 items, but no more than the target completions
const maxItems = Math.min(7, total)
// Calculate start position to center current completion
const start = Math.max(0, Math.min(todayCompletions - Math.floor(maxItems / 2), total - maxItems))
return Array.from({ length: maxItems }).map((_, i) => {
const cycle = start + i
const isCompleted = cycle < todayCompletions
const isCurrent = cycle === todayCompletions
return (
<div
key={cycle}
className={cn(
'w-6 h-6 rounded-full flex items-center justify-center border',
isCompleted
? 'bg-green-500 border-green-600 text-white'
: isCurrent
? 'border-2 border-green-500 text-muted-foreground'
: 'border-muted-foreground text-muted-foreground'
)}
>
{cycle + 1}
</div>
)
})
})()}
</div>
)}
</div>
<Progress value={progress} className="h-2 w-full" />
<div className="flex gap-2">
<Button onClick={toggleTimer} className="sm:px-4">
{state === "started" ? (
<>
<Pause className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Pause</span>
</>
) : (
<>
<Play className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Start</span>
</>
)}
</Button>
<Button
variant="outline"
onClick={resetTimer}
disabled={state === "started"}
className="sm:px-4"
>
<RotateCw className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Reset</span>
</Button>
<Button
variant="outline"
onClick={() => {
currentTimer.current = currentTimer.current.type === 'focus'
? PomoConfigs.break
: PomoConfigs.focus
resetTimer()
}}
disabled={state === "started"}
className="sm:px-4"
>
<SkipForward className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Skip</span>
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
const [settings] = useAtom(settingsAtom)
const { coinsEarnedToday } = useCoins()
if (coinsEarnedToday <= 0) return null
return (
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">
{"+"}
<FormattedNumber amount={coinsEarnedToday} settings={settings} />
{longFormat ?
<span className="text-sm text-muted-foreground"> today</span>
: null}
</span>
)
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}