mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-20 22:24:28 +01:00
Added PWA support (#40)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ next-env.d.ts
|
|||||||
# customize
|
# customize
|
||||||
data/*
|
data/*
|
||||||
Budfile
|
Budfile
|
||||||
|
certificates
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Version 0.1.19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- PWA support to allow installing app to mobile (#39)
|
||||||
|
- right click context menu for habits
|
||||||
|
- Pomodoro clock
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- disable today's earned SSR (#38)
|
||||||
|
|
||||||
## Version 0.1.18
|
## Version 0.1.18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
49
app/actions/push.ts
Normal file
49
app/actions/push.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import webpush from 'web-push'
|
||||||
|
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
'mailto:mydohsimpson@gmail.com',
|
||||||
|
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
|
||||||
|
process.env.VAPID_PRIVATE_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
interface PushSubscriptionWithKeys extends PushSubscription {
|
||||||
|
keys: {
|
||||||
|
p256dh: string
|
||||||
|
auth: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscription: PushSubscriptionWithKeys | null = null
|
||||||
|
|
||||||
|
export async function subscribeUser(sub: PushSubscriptionWithKeys) {
|
||||||
|
subscription = sub
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribeUser() {
|
||||||
|
subscription = null
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNotification(message: string) {
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error('No subscription available')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
subscription,
|
||||||
|
JSON.stringify({
|
||||||
|
title: 'HabitTrove',
|
||||||
|
body: message,
|
||||||
|
icon: '/icon.png',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending push notification:', error)
|
||||||
|
return { success: false, error: 'Failed to send notification' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,7 @@ import HabitCalendar from '@/components/HabitCalendar'
|
|||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<HabitCalendar />
|
||||||
<HabitCalendar />
|
|
||||||
</Layout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import CoinsManager from '@/components/CoinsManager'
|
|||||||
|
|
||||||
export default function CoinsPage() {
|
export default function CoinsPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<CoinsManager />
|
||||||
<CoinsManager />
|
|
||||||
</Layout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 15 KiB |
@@ -3,9 +3,7 @@ import HabitList from '@/components/HabitList'
|
|||||||
|
|
||||||
export default function HabitsPage() {
|
export default function HabitsPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<HabitList />
|
||||||
<HabitList />
|
|
||||||
</Layout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { Inter } from 'next/font/google'
|
import { Inter } from 'next/font/google'
|
||||||
import { DM_Sans } from 'next/font/google'
|
import { DM_Sans } from 'next/font/google'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
|
||||||
import { JotaiProvider } from '@/components/jotai-providers'
|
import { JotaiProvider } from '@/components/jotai-providers'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||||
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData } from './actions/data'
|
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData } from './actions/data'
|
||||||
|
import Layout from '@/components/Layout'
|
||||||
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
|
|
||||||
// Inter (clean, modern, excellent readability)
|
// Inter (clean, modern, excellent readability)
|
||||||
const inter = Inter({
|
// const inter = Inter({
|
||||||
subsets: ['latin'],
|
// subsets: ['latin'],
|
||||||
weight: ['400', '500', '600', '700']
|
// weight: ['400', '500', '600', '700']
|
||||||
})
|
// })
|
||||||
//
|
|
||||||
// Clean and contemporary
|
// Clean and contemporary
|
||||||
const dmSans = DM_Sans({
|
const dmSans = DM_Sans({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
@@ -25,7 +27,7 @@ export const metadata = {
|
|||||||
description: 'Track your habits and get rewarded',
|
description: 'Track your habits and get rewarded',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic' // needed to prevent nextjs from caching the loadSettings function in Layout component
|
export const dynamic = 'force-dynamic' // needed to prevent nextjs from caching the load... functions in Layout component
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -42,6 +44,23 @@ export default async function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={activeFont.className}>
|
<body className={activeFont.className}>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('ServiceWorker registration successful');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.log('ServiceWorker registration failed: ', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<Suspense fallback="loading">
|
<Suspense fallback="loading">
|
||||||
<JotaiHydrate
|
<JotaiHydrate
|
||||||
@@ -52,7 +71,9 @@ export default async function RootLayout({
|
|||||||
wishlist: initialWishlist
|
wishlist: initialWishlist
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
<Layout>
|
||||||
|
{children}
|
||||||
|
</Layout>
|
||||||
</JotaiHydrate>
|
</JotaiHydrate>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
|
|||||||
25
app/manifest.ts
Normal file
25
app/manifest.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { MetadataRoute } from 'next'
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: 'HabitTrove',
|
||||||
|
short_name: 'HabitTrove',
|
||||||
|
description: 'Gamified habit tracking application',
|
||||||
|
start_url: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
theme_color: '#000000',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icons/web-app-manifest-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icons/web-app-manifest-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import Layout from '@/components/Layout'
|
|
||||||
import Dashboard from '@/components/Dashboard'
|
import Dashboard from '@/components/Dashboard'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Dashboard />
|
||||||
<Dashboard />
|
|
||||||
</Layout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import Layout from '@/components/Layout'
|
|
||||||
|
|
||||||
export default function SettingsLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return <Layout>{children}</Layout>
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,7 @@ import WishlistManager from '@/components/WishlistManager'
|
|||||||
|
|
||||||
export default function WishlistPage() {
|
export default function WishlistPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<WishlistManager />
|
||||||
<WishlistManager />
|
|
||||||
</Layout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
components/ClientWrapper.tsx
Normal file
19
components/ClientWrapper.tsx
Normal 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 />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ import { Coins } from 'lucide-react'
|
|||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
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 }) {
|
export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const { coinsEarnedToday } = useCoins()
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -17,19 +18,14 @@ export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
|
|||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<Coins className="h-12 w-12 text-yellow-400 mr-4" />
|
<Coins className="h-12 w-12 text-yellow-400 mr-4" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-4xl font-bold">
|
<div className="flex flex-col">
|
||||||
<FormattedNumber amount={coinBalance} settings={settings} />
|
<span className="text-4xl font-bold">
|
||||||
</span>
|
<FormattedNumber amount={coinBalance} settings={settings} />
|
||||||
{coinsEarnedToday > 0 && (
|
</span>
|
||||||
<div className="flex items-center gap-1 mt-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-md text-green-600 dark:text-green-400 font-medium">
|
<TodayEarnedCoins longFormat={true} />
|
||||||
+<FormattedNumber amount={coinsEarnedToday} settings={settings} />
|
|
||||||
</span>
|
|
||||||
<span className="text-md text-muted-foreground">
|
|
||||||
today
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -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 { 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 { settingsAtom } from '@/lib/atoms'
|
import { pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletedHabitsForDate, getCompletionsForDate } from '@/lib/utils'
|
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletedHabitsForDate, getCompletionsForDate } 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'
|
||||||
@@ -40,245 +46,181 @@ export default function DailyOverview({
|
|||||||
setDailyHabits(filteredHabits)
|
setDailyHabits(filteredHabits)
|
||||||
}, [habits])
|
}, [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
|
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 [expandedHabits, setExpandedHabits] = useState(false)
|
||||||
const [expandedWishlist, setExpandedWishlist] = useState(false)
|
const [expandedWishlist, setExpandedWishlist] = useState(false)
|
||||||
|
const [_, setPomo] = useAtom(pomodoroAtom)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<>
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Today's Overview</CardTitle>
|
<CardHeader>
|
||||||
</CardHeader>
|
<CardTitle>Today's Overview</CardTitle>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="space-y-4">
|
<CardContent>
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<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>
|
|
||||||
<div>
|
<div>
|
||||||
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-[500px]' : 'max-h-[200px]'} overflow-hidden`}>
|
<div className="flex items-center justify-between mb-2">
|
||||||
{sortedWishlistItems.length === 0 ? (
|
<h3 className="font-semibold">Daily Habits</h3>
|
||||||
<div className="text-center text-muted-foreground text-sm py-4">
|
<Badge variant="secondary">
|
||||||
No wishlist items yet. Add some goals to work towards!
|
{dailyHabits.filter(habit => {
|
||||||
</div>
|
const completions = getCompletionsForDate({
|
||||||
) : (
|
habit,
|
||||||
<>
|
date: today,
|
||||||
{sortedWishlistItems
|
timezone: settings.system.timezone
|
||||||
.slice(0, expandedWishlist ? undefined : 5)
|
});
|
||||||
.map((item) => {
|
return completions >= (habit.targetCompletions || 1);
|
||||||
const isRedeemable = item.coinCost <= coinBalance
|
}).length}/{dailyHabits.length} Completed
|
||||||
return (
|
</Badge>
|
||||||
<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>
|
||||||
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpandedWishlist(!expandedWishlist)}
|
onClick={() => setExpandedHabits(!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"
|
||||||
>
|
>
|
||||||
{expandedWishlist ? (
|
{expandedHabits ? (
|
||||||
<>
|
<>
|
||||||
Show less
|
Show less
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
@@ -291,7 +233,7 @@ export default function DailyOverview({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/wishlist"
|
href="/habits"
|
||||||
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"
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
@@ -299,9 +241,105 @@ export default function DailyOverview({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { wishlistAtom, habitsAtom, settingsAtom, coinsAtom } from '@/lib/atoms'
|
import { wishlistAtom, habitsAtom, settingsAtom, coinsAtom } from '@/lib/atoms'
|
||||||
import CoinBalance from './CoinBalance'
|
|
||||||
import DailyOverview from './DailyOverview'
|
import DailyOverview from './DailyOverview'
|
||||||
import HabitStreak from './HabitStreak'
|
import HabitStreak from './HabitStreak'
|
||||||
|
import CoinBalance from './CoinBalance'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { coinsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
import { Bell, Menu, Settings, User, Info, Coins } from 'lucide-react'
|
import { Bell, Menu, Settings, User, Info, Coins } from 'lucide-react'
|
||||||
@@ -17,15 +17,18 @@ import {
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import AboutModal from './AboutModal'
|
import AboutModal from './AboutModal'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||||
|
|
||||||
export default function Header({ className }: HeaderProps) {
|
export default function Header({ className }: HeaderProps) {
|
||||||
const [showAbout, setShowAbout] = useState(false)
|
const [showAbout, setShowAbout] = useState(false)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const { balance, coinsEarnedToday } = useCoins()
|
const [coins] = useAtom(coinsAtom)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
|
<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" />
|
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
|
||||||
<div className="flex items-baseline gap-1 sm:gap-2">
|
<div className="flex items-baseline gap-1 sm:gap-2">
|
||||||
<FormattedNumber
|
<FormattedNumber
|
||||||
amount={balance}
|
amount={coins.balance}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
|
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
|
||||||
/>
|
/>
|
||||||
{coinsEarnedToday > 0 && (
|
<div className="hidden sm:block">
|
||||||
<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">
|
<TodayEarnedCoins />
|
||||||
<FormattedNumber
|
</div>
|
||||||
amount={coinsEarnedToday}
|
|
||||||
settings={settings}
|
|
||||||
className="inline"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Button variant="ghost" size="icon" aria-label="Notifications">
|
<Button variant="ghost" size="icon" aria-label="Notifications">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import ClientWrapper from './ClientWrapper'
|
||||||
import Header from './Header'
|
import Header from './Header'
|
||||||
import LinkifyComponent from './linkify'
|
|
||||||
import Navigation from './Navigation'
|
import Navigation from './Navigation'
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
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">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<Navigation viewPort='main' />
|
<Navigation viewPort='main' />
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900">
|
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||||
{children}
|
<ClientWrapper>
|
||||||
|
{children}
|
||||||
|
</ClientWrapper>
|
||||||
</main>
|
</main>
|
||||||
<Navigation viewPort='mobile' />
|
<Navigation viewPort='mobile' />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
338
components/PomodoroTimer.tsx
Normal file
338
components/PomodoroTimer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
components/TodayEarnedCoins.tsx
Normal file
21
components/TodayEarnedCoins.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
components/ui/context-menu.tsx
Normal file
200
components/ui/context-menu.tsx
Normal 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,
|
||||||
|
}
|
||||||
@@ -24,6 +24,16 @@ export function useHabits() {
|
|||||||
})
|
})
|
||||||
const target = habit.targetCompletions || 1
|
const target = habit.targetCompletions || 1
|
||||||
|
|
||||||
|
// Check if already completed
|
||||||
|
if (completionsToday >= target) {
|
||||||
|
toast({
|
||||||
|
title: "Already completed",
|
||||||
|
description: `You've already completed this habit today.`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Add new completion
|
// Add new completion
|
||||||
const updatedHabit = {
|
const updatedHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
|
|||||||
38
lib/atoms.ts
38
lib/atoms.ts
@@ -3,7 +3,8 @@ import {
|
|||||||
getDefaultSettings,
|
getDefaultSettings,
|
||||||
getDefaultHabitsData,
|
getDefaultHabitsData,
|
||||||
getDefaultCoinsData,
|
getDefaultCoinsData,
|
||||||
getDefaultWishlistData
|
getDefaultWishlistData,
|
||||||
|
Habit
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
@@ -13,7 +14,8 @@ import {
|
|||||||
calculateTotalEarned,
|
calculateTotalEarned,
|
||||||
calculateTotalSpent,
|
calculateTotalSpent,
|
||||||
calculateCoinsSpentToday,
|
calculateCoinsSpentToday,
|
||||||
calculateTransactionsToday
|
calculateTransactionsToday,
|
||||||
|
getCompletionsForToday
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
|
|
||||||
export const settingsAtom = atom(getDefaultSettings());
|
export const settingsAtom = atom(getDefaultSettings());
|
||||||
@@ -53,3 +55,35 @@ export const transactionsTodayAtom = atom((get) => {
|
|||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* transient atoms */
|
||||||
|
interface PomodoroAtom {
|
||||||
|
show: boolean
|
||||||
|
selectedHabitId: string | null
|
||||||
|
autoStart: boolean
|
||||||
|
minimized: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pomodoroAtom = atom<PomodoroAtom>({
|
||||||
|
show: false,
|
||||||
|
selectedHabitId: null,
|
||||||
|
autoStart: true,
|
||||||
|
minimized: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Derived atom for today's completions of selected habit
|
||||||
|
export const pomodoroTodayCompletionsAtom = atom((get) => {
|
||||||
|
const pomo = get(pomodoroAtom)
|
||||||
|
const habits = get(habitsAtom)
|
||||||
|
const settings = get(settingsAtom)
|
||||||
|
|
||||||
|
if (!pomo.selectedHabitId) return 0
|
||||||
|
|
||||||
|
const selectedHabit = habits.habits.find(h => h.id === pomo.selectedHabitId)
|
||||||
|
if (!selectedHabit) return 0
|
||||||
|
|
||||||
|
return getCompletionsForToday({
|
||||||
|
habit: selectedHabit,
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
10
lib/utils.ts
10
lib/utils.ts
@@ -83,6 +83,16 @@ export function getCompletionsForDate({
|
|||||||
).length
|
).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCompletionsForToday({
|
||||||
|
habit,
|
||||||
|
timezone
|
||||||
|
}: {
|
||||||
|
habit: Habit,
|
||||||
|
timezone: string
|
||||||
|
}): number {
|
||||||
|
return getCompletionsForDate({ habit, date: getTodayInTimezone(timezone), timezone })
|
||||||
|
}
|
||||||
|
|
||||||
export function getCompletedHabitsForDate({
|
export function getCompletedHabitsForDate({
|
||||||
habits,
|
habits,
|
||||||
date,
|
date,
|
||||||
|
|||||||
@@ -9,7 +9,46 @@ const nextConfig: NextConfig = {
|
|||||||
use: 'raw-loader'
|
use: 'raw-loader'
|
||||||
})
|
})
|
||||||
return config
|
return config
|
||||||
}
|
},
|
||||||
|
// PWA
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'X-Content-Type-Options',
|
||||||
|
value: 'nosniff',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'X-Frame-Options',
|
||||||
|
value: 'DENY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Referrer-Policy',
|
||||||
|
value: 'strict-origin-when-cross-origin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/sw.js',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'Content-Type',
|
||||||
|
value: 'application/javascript; charset=utf-8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Cache-Control',
|
||||||
|
value: 'no-cache, no-store, must-revalidate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Content-Security-Policy',
|
||||||
|
value: "default-src 'self'; script-src 'self'",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
159
package-lock.json
generated
159
package-lock.json
generated
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.1.16",
|
"version": "0.1.18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.1.16",
|
"version": "0.1.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@next/font": "^14.2.15",
|
"@next/font": "^14.2.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
@@ -43,7 +44,8 @@
|
|||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"rrule": "^2.8.1",
|
"rrule": "^2.8.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
"@types/node": "^20.17.10",
|
"@types/node": "^20.17.10",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.3",
|
"eslint-config-next": "15.1.3",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
@@ -1035,6 +1038,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-context-menu": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.4.tgz",
|
||||||
|
"integrity": "sha512-ap4wdGwK52rJxGkwukU1NrnEodsUFQIooANKu+ey7d6raQ2biTcEf8za1zr0mgFHieevRTB2nK4dJeN8pTAZGQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.1",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-menu": "2.1.4",
|
||||||
|
"@radix-ui/react-primitive": "2.0.1",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-dialog": {
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz",
|
||||||
@@ -1966,6 +1996,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.5.13",
|
"version": "8.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
||||||
@@ -2413,6 +2452,14 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -2702,6 +2749,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ast-types-flow": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||||
@@ -2775,6 +2833,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
|
||||||
|
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg=="
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
@@ -2829,6 +2892,11 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -3517,6 +3585,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.76",
|
"version": "1.5.76",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz",
|
||||||
@@ -4726,6 +4802,26 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/husky": {
|
"node_modules/husky": {
|
||||||
"version": "9.1.7",
|
"version": "9.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||||
@@ -4775,6 +4871,11 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||||
|
},
|
||||||
"node_modules/inline-style-parser": {
|
"node_modules/inline-style-parser": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
|
||||||
@@ -5405,6 +5506,25 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.0",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -6221,6 +6341,11 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -6237,7 +6362,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
@@ -7308,7 +7432,6 @@
|
|||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -7322,8 +7445,7 @@
|
|||||||
"type": "consulting",
|
"type": "consulting",
|
||||||
"url": "https://feross.org/support"
|
"url": "https://feross.org/support"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/safe-regex-test": {
|
"node_modules/safe-regex-test": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@@ -7342,6 +7464,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.25.0",
|
"version": "0.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||||
@@ -8573,6 +8700,24 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webpack": {
|
"node_modules/webpack": {
|
||||||
"version": "5.97.1",
|
"version": "5.97.1",
|
||||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.1.18",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@next/font": "^14.2.15",
|
"@next/font": "^14.2.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
@@ -50,7 +51,8 @@
|
|||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"rrule": "^2.8.1",
|
"rrule": "^2.8.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -60,6 +62,7 @@
|
|||||||
"@types/node": "^20.17.10",
|
"@types/node": "^20.17.10",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.3",
|
"eslint-config-next": "15.1.3",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
|
|||||||
BIN
public/icons/icon.png
Normal file
BIN
public/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
public/icons/web-app-manifest-192x192.png
Normal file
BIN
public/icons/web-app-manifest-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
public/icons/web-app-manifest-512x512.png
Normal file
BIN
public/icons/web-app-manifest-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
BIN
public/sounds/timer-end.wav
Normal file
BIN
public/sounds/timer-end.wav
Normal file
Binary file not shown.
22
public/sw.js
Normal file
22
public/sw.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
self.addEventListener('push', function (event) {
|
||||||
|
if (event.data) {
|
||||||
|
const data = event.data.json()
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: data.icon || '/icon.png',
|
||||||
|
badge: '/badge.png',
|
||||||
|
vibrate: [100, 50, 100],
|
||||||
|
data: {
|
||||||
|
dateOfArrival: Date.now(),
|
||||||
|
primaryKey: '2',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
event.waitUntil(self.registration.showNotification(data.title, options))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', function (event) {
|
||||||
|
console.log('Notification click received.')
|
||||||
|
event.notification.close()
|
||||||
|
event.waitUntil(clients.openWindow('/'))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user