Compare commits

...

7 Commits

24 changed files with 1350 additions and 340 deletions

1
.gitignore vendored
View File

@ -15,6 +15,7 @@
# next.js
/.next/
/.next*/
/out/
# production

14
prisma.config.ts Normal file
View File

@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@ -19,6 +19,9 @@ model UserPreferences {
dailyGoal Int?
userName String?
userAge Int?
religion String?
lastNicotineUsageTime String?
lastWeedUsageTime String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -33,6 +33,9 @@ export async function GET() {
quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null,
userName: preferences.userName,
userAge: preferences.userAge,
religion: preferences.religion,
lastNicotineUsageTime: preferences.lastNicotineUsageTime,
lastWeedUsageTime: preferences.lastWeedUsageTime,
});
} catch (error) {
console.error('Error fetching preferences:', error);
@ -48,7 +51,18 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
const { substance, trackingStartDate, hasCompletedSetup, dailyGoal, quitPlan, userName, userAge } = body;
const {
substance,
trackingStartDate,
hasCompletedSetup,
dailyGoal,
quitPlan,
userName,
userAge,
religion,
lastNicotineUsageTime,
lastWeedUsageTime
} = body;
const preferences = await prisma.userPreferences.upsert({
where: { userId: session.user.id },
@ -60,6 +74,9 @@ export async function POST(request: NextRequest) {
quitPlanJson: quitPlan ? JSON.stringify(quitPlan) : null,
userName,
userAge,
religion,
lastNicotineUsageTime,
lastWeedUsageTime,
},
create: {
userId: session.user.id,
@ -70,6 +87,9 @@ export async function POST(request: NextRequest) {
quitPlanJson: quitPlan ? JSON.stringify(quitPlan) : null,
userName,
userAge,
religion,
lastNicotineUsageTime,
lastWeedUsageTime,
},
});
@ -81,6 +101,9 @@ export async function POST(request: NextRequest) {
quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null,
userName: preferences.userName,
userAge: preferences.userAge,
religion: preferences.religion,
lastNicotineUsageTime: preferences.lastNicotineUsageTime,
lastWeedUsageTime: preferences.lastWeedUsageTime,
});
} catch (error) {
console.error('Error saving preferences:', error);

View File

@ -0,0 +1,4 @@
import { handleAuth } from "@workos-inc/authkit-nextjs";
// Handle the WorkOS OAuth callback
export const GET = handleAuth({ returnPathname: "/" });

177
src/app/globals 2.css Normal file
View File

@ -0,0 +1,177 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.9789 0.0082 121.6272);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.5106 0.2301 276.9656);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.7038 0.1230 182.5025);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.7686 0.1647 70.0804);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0.5555 0 0);
--ring: oklch(0.7853 0.1041 274.7134);
--chart-1: oklch(0.5106 0.2301 276.9656);
--chart-2: oklch(0.7038 0.1230 182.5025);
--chart-3: oklch(0.7686 0.1647 70.0804);
--chart-4: oklch(0.6559 0.2118 354.3084);
--chart-5: oklch(0.7227 0.1920 149.5793);
--sidebar: oklch(0.9789 0.0082 121.6272);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.5106 0.2301 276.9656);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.7686 0.1647 70.0804);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.7853 0.1041 274.7134);
--radius: 1rem;
--shadow-x: 0px;
--shadow-y: 0px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0.05;
--shadow-color: #1a1a1a;
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
--tracking-normal: normal;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1.0000 0 0);
--card: oklch(0.2455 0.0217 257.2823);
--card-foreground: oklch(1.0000 0 0);
--popover: oklch(0.2455 0.0217 257.2823);
--popover-foreground: oklch(1.0000 0 0);
--primary: oklch(0.6801 0.1583 276.9349);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.7845 0.1325 181.9120);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.3211 0 0);
--muted-foreground: oklch(0.8452 0 0);
--accent: oklch(0.8790 0.1534 91.6054);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.7106 0.1661 22.2162);
--destructive-foreground: oklch(0 0 0);
--border: oklch(0.4459 0 0);
--input: oklch(1.0000 0 0);
--ring: oklch(0.6801 0.1583 276.9349);
--chart-1: oklch(0.6801 0.1583 276.9349);
--chart-2: oklch(0.7845 0.1325 181.9120);
--chart-3: oklch(0.8790 0.1534 91.6054);
--chart-4: oklch(0.7253 0.1752 349.7607);
--chart-5: oklch(0.8003 0.1821 151.7110);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(1.0000 0 0);
--sidebar-primary: oklch(0.6801 0.1583 276.9349);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.8790 0.1534 91.6054);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(1.0000 0 0);
--sidebar-ring: oklch(0.6801 0.1583 276.9349);
--radius: 1rem;
--shadow-x: 0px;
--shadow-y: 0px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0.05;
--shadow-color: #1a1a1a;
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.03);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.05), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0.05);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0.13);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: 'DM Sans', sans-serif;
--font-mono: 'Space Mono', monospace;
--font-serif: 'DM Sans', sans-serif;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-normal: var(--tracking-normal);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
letter-spacing: var(--tracking-normal);
}
}

View File

@ -163,25 +163,33 @@
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
/* Background gradients */
--bg-main: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 20%, #bbf7d0 40%, #dcfce7 60%, #f0fdf4 80%, #dcfce7 100%);
--bg-orbs:
radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.1) 0%, transparent 40%),
radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.08) 0%, transparent 35%),
radial-gradient(ellipse at 50% 50%, rgba(59, 130, 246, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.05) 0%, transparent 40%),
radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.05) 0%, transparent 35%);
}
@layer base {
html {
scroll-behavior: smooth;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply text-foreground;
font-family: var(--font-sans);
letter-spacing: var(--tracking-normal);
background: linear-gradient(135deg,
#0f0f1a 0%,
#1a1a2e 20%,
#16213e 40%,
#1a1a2e 60%,
#0f0f1a 80%,
#1a1a2e 100%);
background-attachment: fixed;
background-color: transparent;
min-height: 100vh;
overflow-x: hidden;
}
body::before {
@ -191,14 +199,21 @@
left: 0;
right: 0;
bottom: 0;
background:
background: var(--bg-orbs), var(--bg-main);
background-size: cover;
pointer-events: none;
z-index: -50;
}
/* Dark mode overrides */
.dark {
--bg-main: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 20%, #16213e 40%, #1a1a2e 60%, #0f0f1a 80%, #1a1a2e 100%);
--bg-orbs:
radial-gradient(ellipse at 15% 10%, rgba(99, 102, 241, 0.15) 0%, transparent 40%),
radial-gradient(ellipse at 85% 20%, rgba(168, 85, 247, 0.12) 0%, transparent 35%),
radial-gradient(ellipse at 50% 50%, rgba(45, 55, 72, 0.1) 0%, transparent 50%),
radial-gradient(ellipse at 20% 80%, rgba(34, 197, 94, 0.08) 0%, transparent 40%),
radial-gradient(ellipse at 80% 85%, rgba(239, 68, 68, 0.08) 0%, transparent 35%);
pointer-events: none;
z-index: -1;
}
/* Calendar styling - reduce overall size */
@ -217,8 +232,13 @@
/* Animation keyframes */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-in-up {
@ -226,6 +246,7 @@
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
@ -237,6 +258,7 @@
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
@ -248,6 +270,7 @@
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
@ -259,6 +282,7 @@
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
@ -270,6 +294,7 @@
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
@ -277,23 +302,49 @@
}
@keyframes pulse-subtle {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@keyframes glow {
0%, 100% { box-shadow: 0 0 5px rgba(99, 102, 241, 0.5); }
50% { box-shadow: 0 0 20px rgba(99, 102, 241, 0.8); }
0%,
100% {
box-shadow: 0 0 5px rgba(99, 102, 241, 0.5);
}
50% {
box-shadow: 0 0 20px rgba(99, 102, 241, 0.8);
}
}
@keyframes confetti {
@ -301,6 +352,7 @@
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
@ -349,15 +401,34 @@
}
/* Stagger delay utilities */
.delay-100 { animation-delay: 100ms; }
.delay-200 { animation-delay: 200ms; }
.delay-300 { animation-delay: 300ms; }
.delay-400 { animation-delay: 400ms; }
.delay-500 { animation-delay: 500ms; }
.delay-600 { animation-delay: 600ms; }
.delay-100 {
animation-delay: 100ms;
}
.delay-200 {
animation-delay: 200ms;
}
.delay-300 {
animation-delay: 300ms;
}
.delay-400 {
animation-delay: 400ms;
}
.delay-500 {
animation-delay: 500ms;
}
.delay-600 {
animation-delay: 600ms;
}
/* Start hidden for animations */
.opacity-0 { opacity: 0; }
.opacity-0 {
opacity: 0;
}
/* Smooth transitions */
.transition-smooth {
@ -474,4 +545,4 @@
opacity: 0.03;
pointer-events: none;
border-radius: inherit;
}
}

26
src/app/layout 2.tsx Normal file
View File

@ -0,0 +1,26 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "QuitTrack - Track Your Journey to Quit Smoking",
description: "Track and manage your smoking habits, set goals, and quit safely with personalized plans.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
</head>
<body className="antialiased">
{children}
</body>
</html>
);
}

13
src/app/page 2.tsx Normal file
View File

@ -0,0 +1,13 @@
import { redirect } from 'next/navigation';
import { getUser } from '@/lib/session';
import { Dashboard } from '@/components/Dashboard';
export default async function Home() {
const user = await getUser();
if (!user) {
redirect('/login');
}
return <Dashboard user={user} />;
}

7
src/app/signout/page.tsx Normal file
View File

@ -0,0 +1,7 @@
import { signOut } from "@workos-inc/authkit-nextjs";
import { redirect } from "next/navigation";
export default async function SignOutPage() {
await signOut();
redirect("/");
}

View File

@ -0,0 +1,244 @@
'use client';
import { useState, useMemo } from 'react';
import { Sparkles, Settings, BookOpen, Quote as QuoteIcon, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { savePreferencesAsync, getPreferences, UserPreferences } from '@/lib/storage';
type Religion = 'christian' | 'muslim' | 'jewish' | 'secular';
interface Verse {
text: string;
source: string;
}
const VERSES: Record<Religion, Verse[]> = {
christian: [
{ text: "I can do all things through Christ which strengtheneth me.", source: "Philippians 4:13 (KJV)" },
{ text: "For God hath not given us the spirit of fear; but of power, and of love, and of a sound mind.", source: "2 Timothy 1:7 (KJV)" },
{ text: "But they that wait upon the LORD shall renew their strength; they shall mount up with wings as eagles.", source: "Isaiah 40:31 (KJV)" },
{ text: "Be strong and of a good courage; be not afraid, neither be thou dismayed: for the LORD thy God is with thee whithersoever thou goest.", source: "Joshua 1:9 (KJV)" },
{ text: "The LORD is my shepherd; I shall not want.", source: "Psalm 23:1 (KJV)" },
{ text: "Trust in the LORD with all thine heart; and lean not unto thine own understanding.", source: "Proverbs 3:5 (KJV)" },
{ text: "And be not conformed to this world: but be ye transformed by the renewing of your mind.", source: "Romans 12:2 (KJV)" },
{ text: "Cast thy burden upon the LORD, and he shall sustain thee.", source: "Psalm 55:22 (KJV)" },
{ text: "Fear thou not; for I am with thee: be not dismayed; for I am thy God: I will strengthen thee.", source: "Isaiah 41:10 (KJV)" },
{ text: "Come unto me, all ye that labour and are heavy laden, and I will give you rest.", source: "Matthew 11:28 (KJV)" },
{ text: "The name of the LORD is a strong tower: the righteous runneth into it, and is safe.", source: "Proverbs 18:10 (KJV)" },
{ text: "Peace I leave with you, my peace I give unto you: not as the world giveth, give I unto you.", source: "John 14:27 (KJV)" },
{ text: "If God be for us, who can be against us?", source: "Romans 8:31 (KJV)" },
{ text: "He healeth the broken in heart, and bindeth up their wounds.", source: "Psalm 147:3 (KJV)" },
{ text: "Wait on the LORD: be of good courage, and he shall strengthen thine heart.", source: "Psalm 27:14 (KJV)" },
{ text: "My flesh and my heart faileth: but God is the strength of my heart, and my portion for ever.", source: "Psalm 73:26 (KJV)" },
{ text: "God is our refuge and strength, a very present help in trouble.", source: "Psalm 46:1 (KJV)" },
{ text: "In him was life; and the life was the light of men.", source: "John 1:4 (KJV)" },
{ text: "Therefore if any man be in Christ, he is a new creature: old things are passed away; behold, all things are become new.", source: "2 Corinthians 5:17 (KJV)" },
{ text: "The LORD shall fight for you, and ye shall hold your peace.", source: "Exodus 14:14 (KJV)" },
],
muslim: [
{ text: "Indeed, with hardship [will be] ease.", source: "Surah Ash-Sharh 94:6" },
{ text: "Allah does not burden a soul beyond that it can bear.", source: "Surah Al-Baqarah 2:286" },
{ text: "So remember Me; I will remember you.", source: "Surah Al-Baqarah 2:152" },
{ text: "And whosoever fears Allah... He will make a way for him to get out (from every difficulty).", source: "Surah At-Talaq 65:2" },
{ text: "Indeed, Allah is with the patient.", source: "Surah Al-Baqarah 2:153" },
{ text: "Call upon Me; I will respond to you.", source: "Surah Ghafir 40:60" },
{ text: "And He found you lost and guided [you].", source: "Surah Ad-Duhaa 93:7" },
{ text: "Unquestionably, by the remembrance of Allah hearts are assured.", source: "Surah Ar-Ra'd 13:28" },
{ text: "And put your trust in Allah, and sufficient is Allah as a Disposer of affairs.", source: "Surah Al-Ahzab 33:3" },
{ text: "Verily, in the remembrance of Allah do hearts find rest.", source: "Surah Ar-Ra'd 13:28" },
{ text: "Our Lord, pour upon us patience and plant firmly our feet.", source: "Surah Al-Baqarah 2:250" },
{ text: "If Allah should aid you, no one can overcome you.", source: "Surah Ali 'Imran 3:160" },
{ text: "Indeed, my Lord is near and responsive.", source: "Surah Hud 11:61" },
{ text: "And seek help through patience and prayer, and indeed, it is difficult except for the humbly submissive.", source: "Surah Al-Baqarah 2:45" },
{ text: "So surely with hardship comes ease.", source: "Surah Ash-Sharh 94:5" },
{ text: "Do not despair of the mercy of Allah.", source: "Surah Az-Zumar 39:53" },
{ text: "He knows what is in every heart.", source: "Surah Al-Mulk 67:13" },
{ text: "Allah is the best of planners.", source: "Surah Al-Anfal 8:30" },
{ text: "My Lord, indeed I am, for whatever good You would send down to me, in need.", source: "Surah Al-Qasas 28:24" },
{ text: "Indeed, good deeds do away with misdeeds.", source: "Surah Hud 11:114" },
],
jewish: [
{ text: "Be strong and of good courage.", source: "Joshua 1:9 (Tanakh)" },
{ text: "I have set the LORD always before me; because He is at my right hand, I shall not be moved.", source: "Psalm 16:8 (Tanakh)" },
{ text: "The LORD is my light and my salvation; whom shall I fear?", source: "Psalm 27:1 (Tanakh)" },
{ text: "Trust in the LORD with all your heart, and do not rely on your own understanding.", source: "Proverbs 3:5 (Tanakh)" },
{ text: "Create in me a clean heart, O God, and put a new and right spirit within me.", source: "Psalm 51:10 (Tanakh)" },
{ text: "Where there is no vision, the people perish.", source: "Proverbs 29:18 (Tanakh)" },
{ text: "A righteous man falls seven times, and rises up again.", source: "Proverbs 24:16 (Tanakh)" },
{ text: "Do not fear, for I am with you; do not be dismayed, for I am your God.", source: "Isaiah 41:10 (Tanakh)" },
{ text: "As water reflects the face, so ones life reflects the heart.", source: "Proverbs 27:19 (Tanakh)" },
{ text: "If I am not for myself, who will be for me? And if I am only for myself, what am I?", source: "Pirkei Avot 1:14" },
{ text: "The world stands on three things: Torah, service, and acts of loving kindness.", source: "Pirkei Avot 1:2" },
{ text: "According to the effort is the reward.", source: "Pirkei Avot 5:23" },
{ text: "It is not your duty to finish the work, but neither are you at liberty to neglect it.", source: "Pirkei Avot 2:16" },
{ text: "Who is wise? One who learns from every man. Who is strong? One who overpowers his inclinations.", source: "Pirkei Avot 4:1" },
{ text: "Even in laughter the heart may ache, and the end of joy may be grief.", source: "Proverbs 14:13 (Tanakh)" },
{ text: "A soft answer turns away wrath, but a harsh word stirs up anger.", source: "Proverbs 15:1 (Tanakh)" },
{ text: "He who walks with integrity walks securely.", source: "Proverbs 10:9 (Tanakh)" },
{ text: "The candle of God is the soul of man.", source: "Proverbs 20:27 (Tanakh)" },
{ text: "There is a time for everything, and a season for every activity under the heavens.", source: "Ecclesiastes 3:1 (Tanakh)" },
{ text: "What you hate, do not do to your neighbor.", source: "Hillel the Elder (Shabbat 31a)" },
],
secular: [
{ text: "The only way to do great work is to love what you do.", source: "Steve Jobs" },
{ text: "It is during our darkest moments that we must focus to see the light.", source: "Aristotle" },
{ text: "The greatest glory in living lies not in never falling, but in rising every time we fall.", source: "Nelson Mandela" },
{ text: "In the middle of difficulty lies opportunity.", source: "Albert Einstein" },
{ text: "What you get by achieving your goals is not as important as what you become by achieving your goals.", source: "Zig Ziglar" },
{ text: "The future belongs to those who believe in the beauty of their dreams.", source: "Eleanor Roosevelt" },
{ text: "It does not matter how slowly you go as long as you do not stop.", source: "Confucius" },
{ text: "Everything you've ever wanted is on the other side of fear.", source: "George Addair" },
{ text: "The best time to plant a tree was 20 years ago. The second best time is now.", source: "Chinese Proverb" },
{ text: "You are never too old to set another goal or to dream a new dream.", source: "C.S. Lewis" },
{ text: "The only impossible journey is the one you never begin.", source: "Tony Robbins" },
{ text: "Success is not final, failure is not fatal: it is the courage to continue that counts.", source: "Winston Churchill" },
{ text: "Believe you can and you're halfway there.", source: "Theodore Roosevelt" },
{ text: "The pain you feel today will be the strength you feel tomorrow.", source: "Arnold Schwarzenegger" },
{ text: "Your life does not get better by chance, it gets better by change.", source: "Jim Rohn" },
{ text: "The secret of change is to focus all of your energy not on fighting the old, but on building the new.", source: "Socrates" },
{ text: "What lies behind us and what lies before us are tiny matters compared to what lies within us.", source: "Ralph Waldo Emerson" },
{ text: "The man who moves a mountain begins by carrying away small stones.", source: "Confucius" },
{ text: "Our greatest weakness lies in giving up. The most certain way to succeed is always to try just one more time.", source: "Thomas Edison" },
{ text: "Fall seven times, stand up eight.", source: "Japanese Proverb" },
]
};
interface DailyInspirationCardProps {
initialReligion?: Religion | null;
onReligionChange?: (religion: Religion) => void;
}
export function DailyInspirationCard({ initialReligion, onReligionChange }: DailyInspirationCardProps) {
const [currentReligion, setCurrentReligion] = useState<Religion>(initialReligion || 'secular');
// Get a quote based on the day of the year and selected religion
const dailyVerse = useMemo(() => {
const verses = VERSES[currentReligion];
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const diff = now.getTime() - start.getTime();
const oneDay = 1000 * 60 * 60 * 24;
const dayOfYear = Math.floor(diff / oneDay);
return verses[dayOfYear % verses.length];
}, [currentReligion]);
const handleReligionChange = async (religion: Religion) => {
setCurrentReligion(religion);
// Save to backend if we can
const prefs = getPreferences();
if (prefs) {
const updatedPrefs: UserPreferences = {
...prefs,
religion: religion
};
await savePreferencesAsync(updatedPrefs);
}
if (onReligionChange) {
onReligionChange(religion);
}
};
const getLabel = (r: Religion) => {
switch (r) {
case 'christian': return 'Christian (Bible)';
case 'muslim': return 'Muslim (Quran)';
case 'jewish': return 'Jewish (Torah)';
case 'secular': return 'Secular (Quotes)';
}
};
const getIcon = () => {
switch (currentReligion) {
case 'christian':
case 'muslim':
case 'jewish':
return <BookOpen className="h-4 w-4 text-yellow-300 animate-pulse-subtle" />;
default:
return <Sparkles className="h-4 w-4 text-yellow-300 animate-pulse-subtle" />;
}
};
const getBackground = () => {
switch (currentReligion) {
case 'christian':
return 'linear-gradient(135deg, rgba(59, 130, 246, 0.35) 0%, rgba(37, 99, 235, 0.3) 50%, rgba(30, 64, 175, 0.4) 100%)'; // Blue
case 'muslim':
return 'linear-gradient(135deg, rgba(16, 185, 129, 0.35) 0%, rgba(5, 150, 105, 0.3) 50%, rgba(4, 120, 87, 0.4) 100%)'; // Emerald
case 'jewish':
return 'linear-gradient(135deg, rgba(147, 51, 234, 0.35) 0%, rgba(126, 34, 206, 0.3) 50%, rgba(107, 33, 168, 0.4) 100%)'; // Purple
default:
return 'linear-gradient(135deg, rgba(67, 56, 202, 0.35) 0%, rgba(109, 40, 217, 0.3) 50%, rgba(76, 29, 149, 0.4) 100%)'; // Indigo/Original
}
};
const getShadowColor = () => {
switch (currentReligion) {
case 'christian': return 'rgba(59, 130, 246, 0.15)';
case 'muslim': return 'rgba(16, 185, 129, 0.15)';
case 'jewish': return 'rgba(147, 51, 234, 0.15)';
default: return 'rgba(99, 102, 241, 0.15)';
}
};
return (
<div
className="flex-1 flex flex-col justify-center p-5 rounded-xl border border-white/10 min-h-[120px] relative overflow-hidden group"
style={{
background: getBackground(),
boxShadow: `inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 4px 20px ${getShadowColor()}`
}}
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-white/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="relative z-10">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{getIcon()}
<span className="text-xs font-semibold text-white/80 uppercase tracking-wider">
{currentReligion === 'secular' ? 'Daily Inspiration' : 'Daily Verse'}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-white/50 hover:text-white hover:bg-white/10 rounded-full"
>
<Settings className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(['christian', 'muslim', 'jewish', 'secular'] as Religion[]).map((r) => (
<DropdownMenuItem
key={r}
onClick={() => handleReligionChange(r)}
className="flex items-center justify-between gap-2"
>
<span>{getLabel(r)}</span>
{currentReligion === r && <Check className="h-4 w-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<p className="text-sm font-medium text-white leading-relaxed mb-3 text-shadow-sm italic">
&ldquo;{dailyVerse.text}&rdquo;
</p>
<p className="text-xs text-white/70 font-medium">
{dailyVerse.source}
</p>
</div>
</div>
);
}

View File

@ -64,6 +64,7 @@ export function Dashboard({ user }: DashboardProps) {
setUsageData(usage);
setAchievements(achvs);
setSavingsConfig(savings);
console.log('[Dashboard] Loaded prefs:', prefs);
setRefreshKey(prev => prev + 1);
return { prefs, usage, achvs };
}, []);
@ -116,7 +117,7 @@ export function Dashboard({ user }: DashboardProps) {
init();
}, [loadData, checkAndUnlockAchievements]);
const handleSetupComplete = async (data: { substance: 'nicotine' | 'weed'; name: string; age: number }) => {
const handleSetupComplete = async (data: { substance: 'nicotine' | 'weed'; name: string; age: number; religion: 'christian' | 'muslim' | 'jewish' | 'secular' }) => {
const today = new Date().toISOString().split('T')[0];
const newPrefs: UserPreferences = {
substance: data.substance,
@ -126,6 +127,7 @@ export function Dashboard({ user }: DashboardProps) {
quitPlan: null,
userName: data.name,
userAge: data.age,
religion: data.religion,
};
await savePreferencesAsync(newPrefs);
setPreferences(newPrefs);
@ -142,11 +144,21 @@ export function Dashboard({ user }: DashboardProps) {
if (count > 0) {
const today = new Date().toISOString().split('T')[0];
const now = new Date().toISOString();
await saveUsageEntryAsync({
date: today,
count,
substance,
});
// Update preferences with last usage time
const updatedPrefs = {
...preferences,
[substance === 'nicotine' ? 'lastNicotineUsageTime' : 'lastWeedUsageTime']: now,
};
await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs);
}
setShowUsagePrompt(false);
@ -187,13 +199,11 @@ export function Dashboard({ user }: DashboardProps) {
);
}
const pageBackground = theme === 'dark'
? 'linear-gradient(135deg, #0a0a14 0%, #141e3c 50%, #0f1932 100%)'
: 'linear-gradient(135deg, #ffffff 0%, #f0f4f8 50%, #e8ecf0 100%)';
return (
<div className="min-h-screen" style={{ background: pageBackground }}>
<UserHeader user={user} />
<div className="min-h-screen">
<UserHeader user={user} preferences={preferences} />
<main className="container mx-auto px-4 py-8">
{preferences && (
@ -218,6 +228,17 @@ export function Dashboard({ user }: DashboardProps) {
usageData={usageData}
onDataUpdate={loadData}
userId={user.id}
religion={preferences.religion}
onReligionUpdate={async (religion) => {
const updatedPrefs = { ...preferences, religion };
setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs);
}}
preferences={preferences}
onPreferencesUpdate={async (updatedPrefs) => {
await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs);
}}
/>
</div>
<div className="opacity-0 animate-fade-in-up delay-200">
@ -232,7 +253,7 @@ export function Dashboard({ user }: DashboardProps) {
<HealthTimelineCard
key={`health-${refreshKey}`}
usageData={usageData}
substance={preferences.substance}
preferences={preferences}
/>
</div>
</div>

View File

@ -1,8 +1,8 @@
'use client';
import { useMemo } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { HEALTH_MILESTONES, getMinutesSinceQuit, UsageEntry } from '@/lib/storage';
import { HEALTH_MILESTONES, UsageEntry, UserPreferences } from '@/lib/storage';
import { useTheme } from '@/lib/theme-context';
import {
Heart,
@ -14,12 +14,13 @@ import {
Sparkles,
HeartHandshake,
CheckCircle2,
Clock,
Cigarette,
Leaf
} from 'lucide-react';
interface HealthTimelineCardProps {
usageData: UsageEntry[];
substance: 'nicotine' | 'weed';
preferences?: UserPreferences | null;
}
const iconMap: Record<string, React.ElementType> = {
@ -33,8 +34,60 @@ const iconMap: Record<string, React.ElementType> = {
HeartHandshake,
};
// Simple, direct calculation of minutes since last usage
function calculateMinutesFree(
substance: 'nicotine' | 'weed',
usageData: UsageEntry[],
preferences: UserPreferences | null
): number {
const now = new Date();
// 1. Check for stored timestamp first (most accurate)
const lastUsageTime = substance === 'nicotine'
? preferences?.lastNicotineUsageTime
: preferences?.lastWeedUsageTime;
if (lastUsageTime) {
const lastTime = new Date(lastUsageTime);
const diffMs = now.getTime() - lastTime.getTime();
return Math.max(0, diffMs / (1000 * 60));
}
// 2. Find last recorded usage from usage data
const substanceData = usageData
.filter(e => e.substance === substance && e.count > 0)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (substanceData.length > 0) {
const lastDateStr = substanceData[0].date;
const todayStr = now.toISOString().split('T')[0];
// If last usage was today but no timestamp, count from now (0 minutes)
if (lastDateStr === todayStr) {
return 0;
}
// For past days, count from end of that day (23:59:59)
const lastDate = new Date(lastDateStr);
lastDate.setHours(23, 59, 59, 999);
const diffMs = now.getTime() - lastDate.getTime();
return Math.max(0, diffMs / (1000 * 60));
}
// 3. No usage ever - count from tracking start date
if (preferences?.trackingStartDate) {
const startDate = new Date(preferences.trackingStartDate);
startDate.setHours(0, 0, 0, 0);
const diffMs = now.getTime() - startDate.getTime();
return Math.max(0, diffMs / (1000 * 60));
}
return 0;
}
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
if (minutes < 1) return '< 1 min';
if (minutes < 60) return `${Math.floor(minutes)} min`;
if (minutes < 1440) return `${Math.floor(minutes / 60)} hrs`;
if (minutes < 10080) return `${Math.floor(minutes / 1440)} days`;
if (minutes < 43200) return `${Math.floor(minutes / 10080)} weeks`;
@ -48,145 +101,171 @@ function formatTimeRemaining(currentMinutes: number, targetMinutes: number): str
return `${formatDuration(remaining)} to go`;
}
export function HealthTimelineCard({ usageData, substance }: HealthTimelineCardProps) {
interface TimelineColumnProps {
substance: 'nicotine' | 'weed';
minutesFree: number;
theme: 'light' | 'dark';
}
function TimelineColumn({ substance, minutesFree, theme }: TimelineColumnProps) {
// Find current milestone
let currentMilestoneIndex = -1;
for (let i = HEALTH_MILESTONES.length - 1; i >= 0; i--) {
if (minutesFree >= HEALTH_MILESTONES[i].timeMinutes) {
currentMilestoneIndex = i;
break;
}
}
// Find next milestone
const nextMilestoneIndex = currentMilestoneIndex + 1;
const nextMilestone = nextMilestoneIndex < HEALTH_MILESTONES.length
? HEALTH_MILESTONES[nextMilestoneIndex]
: null;
// Calculate progress to next milestone
let progressToNext = 100;
if (nextMilestone) {
const prevMinutes = currentMilestoneIndex >= 0
? HEALTH_MILESTONES[currentMilestoneIndex].timeMinutes
: 0;
const range = nextMilestone.timeMinutes - prevMinutes;
const progress = minutesFree - prevMinutes;
progressToNext = Math.min(100, Math.max(0, (progress / range) * 100));
}
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
const SubstanceIcon = substance === 'nicotine' ? Cigarette : Leaf;
const accentColorClass = substance === 'nicotine' ? 'text-red-500' : 'text-green-500';
const bgAccentClass = substance === 'nicotine' ? 'bg-red-500' : 'bg-green-500';
return (
<div className="flex flex-col h-full bg-black/5 dark:bg-white/5 rounded-xl border border-white/5 overflow-hidden">
{/* Header with live timer */}
<div className={`p-3 border-b border-white/5 flex items-center gap-2 ${theme === 'light' ? 'bg-white/50' : 'bg-black/20'}`}>
<SubstanceIcon className={`h-4 w-4 ${accentColorClass}`} />
<span className={`text-sm font-semibold ${theme === 'light' ? 'text-slate-900' : 'text-white'}`}>
{substanceLabel}
</span>
<span className="ml-auto text-xs opacity-70 font-medium tabular-nums">
{formatDuration(minutesFree)} free
</span>
</div>
<div className="p-3 flex-1 overflow-y-auto min-h-0 space-y-3 custom-scrollbar">
{/* Progress to next milestone */}
{nextMilestone && (
<div className={`p-3 rounded-lg border ${theme === 'light' ? 'bg-white border-slate-200 shadow-sm' : 'bg-white/5 border-white/10'}`}>
<div className="flex items-center justify-between mb-2">
<span className={`text-xs font-medium opacity-80 ${theme === 'light' ? 'text-slate-700' : 'text-white'}`}>Next Up</span>
<span className={`text-xs font-bold ${accentColorClass}`}>
{formatTimeRemaining(minutesFree, nextMilestone.timeMinutes)}
</span>
</div>
<div className="w-full bg-slate-200 dark:bg-white/10 rounded-full h-1.5 overflow-hidden">
<div
className={`h-1.5 rounded-full transition-all duration-700 ${bgAccentClass}`}
style={{ width: `${progressToNext}%`, opacity: 0.8 }}
/>
</div>
<p className={`text-xs mt-1.5 opacity-70 truncate ${theme === 'light' ? 'text-slate-600' : 'text-white/70'}`}>
{nextMilestone.title}
</p>
</div>
)}
{/* Timeline Items */}
{HEALTH_MILESTONES.map((milestone, index) => {
const isAchieved = minutesFree >= milestone.timeMinutes;
const isCurrent = index === currentMilestoneIndex;
const Icon = iconMap[milestone.icon] || Heart;
return (
<div
key={milestone.id}
className={`flex items-start gap-2.5 p-2 rounded-lg transition-all ${isAchieved
? (theme === 'light' ? 'bg-slate-100/50' : 'bg-white/5')
: 'opacity-50 grayscale'
} ${isCurrent ? 'ring-1 ring-offset-1 ring-offset-transparent ' + (substance === 'nicotine' ? 'ring-red-500/50' : 'ring-green-500/50') : ''}`}
>
{/* Icon */}
<div
className={`p-1.5 rounded-full shrink-0 mt-0.5 ${isAchieved
? (theme === 'light' ? 'bg-white text-slate-700 shadow-sm' : 'bg-white/10 text-white')
: 'bg-black/5 text-black/30 dark:bg-white/5 dark:text-white/30'
}`}
>
{isAchieved ? <CheckCircle2 className="h-3 w-3" /> : <Icon className="h-3 w-3" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={`text-xs font-semibold ${theme === 'light' ? 'text-slate-900' : 'text-white'}`}>
{milestone.title}
</p>
<p className={`text-[10px] mt-0.5 leading-tight ${theme === 'light' ? 'text-slate-600' : 'text-white/50'}`}>
{milestone.description}
</p>
</div>
</div>
);
})}
</div>
</div>
);
}
export function HealthTimelineCard({ usageData, preferences }: HealthTimelineCardProps) {
const { theme } = useTheme();
const minutesSinceQuit = useMemo(() => {
return getMinutesSinceQuit(usageData, substance);
}, [usageData, substance]);
// State for live timer values
const [nicotineMinutes, setNicotineMinutes] = useState(0);
const [weedMinutes, setWeedMinutes] = useState(0);
const currentMilestoneIndex = useMemo(() => {
for (let i = HEALTH_MILESTONES.length - 1; i >= 0; i--) {
if (minutesSinceQuit >= HEALTH_MILESTONES[i].timeMinutes) {
return i;
}
}
return -1;
}, [minutesSinceQuit]);
// Function to recalculate both timers
const updateTimers = useCallback(() => {
const prefs = preferences || null;
setNicotineMinutes(calculateMinutesFree('nicotine', usageData, prefs));
setWeedMinutes(calculateMinutesFree('weed', usageData, prefs));
}, [usageData, preferences]);
const nextMilestone = useMemo(() => {
const nextIndex = currentMilestoneIndex + 1;
if (nextIndex < HEALTH_MILESTONES.length) {
return HEALTH_MILESTONES[nextIndex];
}
return null;
}, [currentMilestoneIndex]);
// Initial calculation and start interval
useEffect(() => {
// Calculate immediately
updateTimers();
const progressToNext = useMemo(() => {
if (!nextMilestone) return 100;
const prevMinutes =
currentMilestoneIndex >= 0
? HEALTH_MILESTONES[currentMilestoneIndex].timeMinutes
: 0;
const range = nextMilestone.timeMinutes - prevMinutes;
const progress = minutesSinceQuit - prevMinutes;
return Math.min(100, Math.max(0, (progress / range) * 100));
}, [minutesSinceQuit, nextMilestone, currentMilestoneIndex]);
// Update every second
const interval = setInterval(updateTimers, 1000);
return () => clearInterval(interval);
}, [updateTimers]);
const cardBackground =
theme === 'light'
? 'linear-gradient(135deg, rgba(6, 95, 70, 0.85) 0%, rgba(4, 120, 87, 0.9) 100%)'
? 'linear-gradient(135deg, rgba(236, 253, 245, 0.9) 0%, rgba(209, 250, 229, 0.8) 100%)'
: 'linear-gradient(135deg, rgba(20, 184, 166, 0.2) 0%, rgba(6, 182, 212, 0.15) 100%)';
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
return (
<Card
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative h-[500px] flex flex-col"
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-teal-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<Heart className="h-5 w-5 text-teal-400" />
<CardHeader className="relative z-10 pb-4 shrink-0">
<CardTitle className={`flex items-center gap-2 ${theme === 'light' ? 'text-teal-900' : 'text-white'} text-shadow-sm`}>
<Heart className="h-5 w-5 text-teal-500" />
<span>Health Recovery</span>
</CardTitle>
<p className="text-sm text-white/70">
{substanceLabel}-free for {formatDuration(minutesSinceQuit)}
<p className={`text-sm ${theme === 'light' ? 'text-teal-700' : 'text-white/70'}`}>
Track your body&apos;s healing process for each substance independently.
</p>
</CardHeader>
<CardContent className="relative z-10">
{/* Progress to next milestone */}
{nextMilestone && (
<div className="mb-4 p-3 bg-teal-500/20 rounded-xl border border-teal-500/30">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-white/80">Next milestone</span>
<span className="text-sm text-teal-300 font-medium">
{formatTimeRemaining(minutesSinceQuit, nextMilestone.timeMinutes)}
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
<div
className="bg-gradient-to-r from-teal-400 to-cyan-400 h-2 rounded-full transition-all duration-700"
style={{ width: `${progressToNext}%` }}
/>
</div>
<p className="text-xs text-white/60 mt-2">{nextMilestone.title}</p>
</div>
)}
{/* Timeline */}
<div className="space-y-3 max-h-[300px] overflow-y-auto pr-2">
{HEALTH_MILESTONES.map((milestone, index) => {
const isAchieved = minutesSinceQuit >= milestone.timeMinutes;
const isCurrent = index === currentMilestoneIndex;
const Icon = iconMap[milestone.icon] || Heart;
return (
<div
key={milestone.id}
className={`flex items-start gap-3 p-2 rounded-lg transition-all ${
isAchieved
? 'bg-teal-500/20'
: 'bg-white/5 opacity-60'
} ${isCurrent ? 'ring-2 ring-teal-400/50' : ''}`}
>
{/* Icon */}
<div
className={`p-2 rounded-full shrink-0 ${
isAchieved
? 'bg-teal-500/30 text-teal-300'
: 'bg-white/10 text-white/40'
}`}
>
{isAchieved ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p
className={`text-sm font-medium ${
isAchieved ? 'text-white' : 'text-white/60'
}`}
>
{milestone.title}
</p>
{isCurrent && (
<span className="text-[10px] bg-teal-500 text-white px-1.5 py-0.5 rounded-full">
Current
</span>
)}
</div>
<p className="text-xs text-white/50 mt-0.5">
{milestone.description}
</p>
<div className="flex items-center gap-1 mt-1">
<Clock className="h-3 w-3 text-white/40" />
<span className="text-[10px] text-white/40">
{formatDuration(milestone.timeMinutes)}
</span>
</div>
</div>
</div>
);
})}
<CardContent className="relative z-10 flex-1 min-h-0 pb-6 pt-0">
<div className="grid grid-cols-2 gap-4 h-full">
<TimelineColumn substance="nicotine" minutesFree={nicotineMinutes} theme={theme} />
<TimelineColumn substance="weed" minutesFree={weedMinutes} theme={theme} />
</div>
</CardContent>
</Card>

View File

@ -79,7 +79,7 @@ export function SavingsSetupDialog({
costPerUnit: cost,
unitsPerDay: units,
currency,
substance,
substance: substance as 'nicotine' | 'weed',
savingsGoal: savingsGoal ? parseFloat(savingsGoal) : null,
goalName: goalName.trim() || null,
};
@ -97,13 +97,13 @@ export function SavingsSetupDialog({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-md bg-emerald-950 border-emerald-800 text-white">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<DollarSign className="h-5 w-5 text-emerald-400" />
<DialogTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-emerald-500" />
{existingConfig ? 'Edit Savings Tracker' : 'Set Up Savings Tracker'}
</DialogTitle>
<DialogDescription className="text-emerald-200">
<DialogDescription>
Enter your usage costs to track how much you&apos;re saving
</DialogDescription>
</DialogHeader>
@ -111,9 +111,9 @@ export function SavingsSetupDialog({
<div className="space-y-4 py-4">
{/* Substance Selection */}
<div className="space-y-2">
<Label className="text-white">What are you tracking?</Label>
<Label>What are you tracking?</Label>
<Select value={substance} onValueChange={(v) => setSubstance(v as 'nicotine' | 'weed')}>
<SelectTrigger className="border-emerald-700 bg-emerald-900/50 text-white">
<SelectTrigger>
<SelectValue placeholder="Select substance" />
</SelectTrigger>
<SelectContent>
@ -125,9 +125,9 @@ export function SavingsSetupDialog({
{/* Currency Selection */}
<div className="space-y-2">
<Label className="text-white">Currency</Label>
<Label>Currency</Label>
<Select value={currency} onValueChange={setCurrency}>
<SelectTrigger className="border-emerald-700 bg-emerald-900/50 text-white">
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
@ -142,11 +142,11 @@ export function SavingsSetupDialog({
{/* Cost Per Unit */}
<div className="space-y-2">
<Label htmlFor="costPerUnit" className="text-white">
<Label htmlFor="costPerUnit">
Cost per pack/cartridge/unit
</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-emerald-300">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
{CURRENCIES.find((c) => c.code === currency)?.symbol || '$'}
</span>
<Input
@ -156,18 +156,18 @@ export function SavingsSetupDialog({
step="0.01"
value={costPerUnit}
onChange={(e) => setCostPerUnit(e.target.value)}
className="pl-8 border-emerald-700 bg-emerald-900/50 text-white placeholder:text-emerald-400"
className="pl-8"
placeholder="10.00"
/>
</div>
<p className="text-xs text-emerald-300">
<p className="text-xs text-muted-foreground">
How much does one pack or cartridge cost?
</p>
</div>
{/* Units Per Week */}
<div className="space-y-2">
<Label htmlFor="unitsPerDay" className="text-white">
<Label htmlFor="unitsPerDay">
Packs/vapes per week (before quitting)
</Label>
<Input
@ -178,24 +178,23 @@ export function SavingsSetupDialog({
value={unitsPerDay}
onChange={(e) => setUnitsPerDay(e.target.value)}
placeholder="1"
className="border-emerald-700 bg-emerald-900/50 text-white placeholder:text-emerald-400"
/>
<p className="text-xs text-emerald-300">
<p className="text-xs text-muted-foreground">
How many packs/vapes did you typically use per week?
</p>
</div>
{/* Optional: Savings Goal */}
<div className="pt-4 border-t border-emerald-800 space-y-4">
<div className="flex items-center gap-2 text-sm text-emerald-300">
<div className="pt-4 border-t space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Target className="h-4 w-4" />
<span>See your real time savings:</span>
</div>
<div className="space-y-2">
<Label htmlFor="savingsGoal" className="text-white">Target amount</Label>
<Label htmlFor="savingsGoal">Target amount</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-emerald-300">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
{CURRENCIES.find((c) => c.code === currency)?.symbol || '$'}
</span>
<Input
@ -205,34 +204,33 @@ export function SavingsSetupDialog({
step="1"
value={savingsGoal}
onChange={(e) => setSavingsGoal(e.target.value)}
className="pl-8 border-emerald-700 bg-emerald-900/50 text-white placeholder:text-emerald-400"
className="pl-8"
placeholder="500"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="goalName" className="text-white">What are you saving for?</Label>
<Label htmlFor="goalName">What are you saving for?</Label>
<Input
id="goalName"
type="text"
value={goalName}
onChange={(e) => setGoalName(e.target.value)}
placeholder="e.g., New Phone, Vacation"
className="border-emerald-700 bg-emerald-900/50 text-white placeholder:text-emerald-400"
/>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-4">
<Button onClick={onClose} className="flex-1 bg-emerald-600 hover:bg-emerald-500 text-white">
<Button onClick={onClose} variant="outline" className="flex-1">
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!isValid}
className="flex-1 bg-emerald-600 hover:bg-emerald-500 text-white"
className="flex-1"
>
{existingConfig ? 'Update' : 'Save'}
</Button>

View File

@ -21,7 +21,7 @@ import {
interface SetupWizardProps {
open: boolean;
onComplete: (data: { substance: 'nicotine' | 'weed'; name: string; age: number }) => void;
onComplete: (data: { substance: 'nicotine' | 'weed'; name: string; age: number; religion: 'christian' | 'muslim' | 'jewish' | 'secular' }) => void;
}
export function SetupWizard({ open, onComplete }: SetupWizardProps) {
@ -29,6 +29,7 @@ export function SetupWizard({ open, onComplete }: SetupWizardProps) {
const [name, setName] = useState('');
const [age, setAge] = useState('25');
const [substance, setSubstance] = useState<'nicotine' | 'weed' | ''>('');
const [religion, setReligion] = useState<'christian' | 'muslim' | 'jewish' | 'secular' | ''>('');
const ages = Array.from({ length: 83 }, (_, i) => (i + 18).toString());
@ -37,15 +38,18 @@ export function SetupWizard({ open, onComplete }: SetupWizardProps) {
setStep(2);
} else if (step === 2 && age) {
setStep(3);
} else if (step === 3 && substance) {
setStep(4);
}
};
const handleComplete = () => {
if (substance && name.trim() && age) {
if (substance && name.trim() && age && religion) {
onComplete({
substance,
name: name.trim(),
age: parseInt(age, 10),
religion: religion,
});
}
};
@ -59,6 +63,7 @@ export function SetupWizard({ open, onComplete }: SetupWizardProps) {
{step === 1 && "Let's get to know you a little better."}
{step === 2 && "Just one more thing about you."}
{step === 3 && "Set up your tracking preferences."}
{step === 4 && "What inspires you?"}
</DialogDescription>
</DialogHeader>
@ -143,7 +148,38 @@ export function SetupWizard({ open, onComplete }: SetupWizardProps) {
<Button variant="outline" onClick={() => setStep(2)} className="flex-1">
Back
</Button>
<Button onClick={handleComplete} disabled={!substance} className="flex-1">
<Button onClick={handleNext} disabled={!substance} className="flex-1">
Continue
</Button>
</div>
</div>
)}
{step === 4 && (
<div className="space-y-4">
<div className="space-y-3">
<Label htmlFor="religion">Choose your daily inspiration</Label>
<Select value={religion} onValueChange={(v) => setReligion(v as 'christian' | 'muslim' | 'jewish' | 'secular')}>
<SelectTrigger id="religion">
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="christian">Christian (Bible Verses)</SelectItem>
<SelectItem value="muslim">Muslim (Quran Verses)</SelectItem>
<SelectItem value="jewish">Jewish (Torah/Tanakh)</SelectItem>
<SelectItem value="secular">Secular (Motivational Quotes)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
We&apos;ll show you a new verse or quote each day to help keep you motivated.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(3)} className="flex-1">
Back
</Button>
<Button onClick={handleComplete} disabled={!religion} className="flex-1">
Start Tracking
</Button>
</div>

View File

@ -12,72 +12,30 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { UsageEntry, setUsageForDateAsync, clearDayDataAsync } from '@/lib/storage';
import { UsageEntry, UserPreferences, setUsageForDateAsync, clearDayDataAsync } from '@/lib/storage';
import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf, Sparkles } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import { DailyInspirationCard } from './DailyInspirationCard';
const quotes = [
{ text: "The only way to do great work is to love what you do.", author: "Steve Jobs" },
{ text: "It is during our darkest moments that we must focus to see the light.", author: "Aristotle" },
{ text: "The greatest glory in living lies not in never falling, but in rising every time we fall.", author: "Nelson Mandela" },
{ text: "In the middle of difficulty lies opportunity.", author: "Albert Einstein" },
{ text: "What you get by achieving your goals is not as important as what you become by achieving your goals.", author: "Zig Ziglar" },
{ text: "The future belongs to those who believe in the beauty of their dreams.", author: "Eleanor Roosevelt" },
{ text: "It does not matter how slowly you go as long as you do not stop.", author: "Confucius" },
{ text: "Everything you've ever wanted is on the other side of fear.", author: "George Addair" },
{ text: "The best time to plant a tree was 20 years ago. The second best time is now.", author: "Chinese Proverb" },
{ text: "You are never too old to set another goal or to dream a new dream.", author: "C.S. Lewis" },
{ text: "The only impossible journey is the one you never begin.", author: "Tony Robbins" },
{ text: "Success is not final, failure is not fatal: it is the courage to continue that counts.", author: "Winston Churchill" },
{ text: "Believe you can and you're halfway there.", author: "Theodore Roosevelt" },
{ text: "The pain you feel today will be the strength you feel tomorrow.", author: "Arnold Schwarzenegger" },
{ text: "Your life does not get better by chance, it gets better by change.", author: "Jim Rohn" },
{ text: "The secret of change is to focus all of your energy not on fighting the old, but on building the new.", author: "Socrates" },
{ text: "What lies behind us and what lies before us are tiny matters compared to what lies within us.", author: "Ralph Waldo Emerson" },
{ text: "The man who moves a mountain begins by carrying away small stones.", author: "Confucius" },
{ text: "Our greatest weakness lies in giving up. The most certain way to succeed is always to try just one more time.", author: "Thomas Edison" },
{ text: "Fall seven times, stand up eight.", author: "Japanese Proverb" },
{ text: "You don't have to be great to start, but you have to start to be great.", author: "Zig Ziglar" },
{ text: "The only person you are destined to become is the person you decide to be.", author: "Ralph Waldo Emerson" },
{ text: "When you reach the end of your rope, tie a knot in it and hang on.", author: "Franklin D. Roosevelt" },
{ text: "Do not wait to strike till the iron is hot; but make it hot by striking.", author: "William Butler Yeats" },
{ text: "Whether you think you can or you think you can't, you're right.", author: "Henry Ford" },
{ text: "The mind is everything. What you think you become.", author: "Buddha" },
{ text: "Strength does not come from physical capacity. It comes from an indomitable will.", author: "Mahatma Gandhi" },
{ text: "A journey of a thousand miles begins with a single step.", author: "Lao Tzu" },
{ text: "He who conquers himself is the mightiest warrior.", author: "Confucius" },
{ text: "The wound is the place where the light enters you.", author: "Rumi" },
{ text: "Rock bottom became the solid foundation on which I rebuilt my life.", author: "J.K. Rowling" },
{ text: "You have power over your mind, not outside events. Realize this, and you will find strength.", author: "Marcus Aurelius" },
{ text: "Out of suffering have emerged the strongest souls.", author: "Kahlil Gibran" },
{ text: "The only limit to our realization of tomorrow will be our doubts of today.", author: "Franklin D. Roosevelt" },
{ text: "Courage is not the absence of fear, but rather the judgment that something else is more important than fear.", author: "Ambrose Redmoon" },
{ text: "Every morning brings new potential, but if you dwell on the misfortunes of the day before, you tend to overlook tremendous opportunities.", author: "Harvey Mackay" },
];
interface UsageCalendarProps {
usageData: UsageEntry[];
onDataUpdate: () => void;
userId: string;
religion?: 'christian' | 'muslim' | 'jewish' | 'secular' | null;
onReligionUpdate?: (religion: 'christian' | 'muslim' | 'jewish' | 'secular') => void;
preferences?: UserPreferences | null;
onPreferencesUpdate?: (prefs: UserPreferences) => Promise<void>;
}
export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [editNicotineCount, setEditNicotineCount] = useState('');
const [editWeedCount, setEditWeedCount] = useState('');
const [isEditing, setIsEditing] = useState(false);
const { theme } = useTheme();
// Get a quote based on the day of the year
const dailyQuote = useMemo(() => {
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const diff = now.getTime() - start.getTime();
const oneDay = 1000 * 60 * 60 * 24;
const dayOfYear = Math.floor(diff / oneDay);
return quotes[dayOfYear % quotes.length];
}, []);
const getUsageForDate = (date: Date, substance: 'nicotine' | 'weed'): number => {
const dateStr = date.toISOString().split('T')[0];
const entry = usageData.find((e) => e.date === dateStr && e.substance === substance);
@ -103,6 +61,7 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
const handleSave = async () => {
if (selectedDate) {
const dateStr = selectedDate.toISOString().split('T')[0];
const todayStr = new Date().toISOString().split('T')[0];
const newNicotineCount = parseInt(editNicotineCount, 10) || 0;
const newWeedCount = parseInt(editWeedCount, 10) || 0;
@ -110,6 +69,25 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
setUsageForDateAsync(dateStr, newNicotineCount, 'nicotine'),
setUsageForDateAsync(dateStr, newWeedCount, 'weed'),
]);
// Update last usage time preferences if editing today's usage and count > 0
if (dateStr === todayStr && preferences && onPreferencesUpdate) {
const now = new Date().toISOString();
const updatedPrefs = { ...preferences };
if (newNicotineCount > 0) {
updatedPrefs.lastNicotineUsageTime = now;
}
if (newWeedCount > 0) {
updatedPrefs.lastWeedUsageTime = now;
}
// Only update if we changed something
if (newNicotineCount > 0 || newWeedCount > 0) {
await onPreferencesUpdate(updatedPrefs);
}
}
onDataUpdate();
}
setIsEditing(false);
@ -205,9 +183,8 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
<button
{...props}
style={!isFuture ? colorStyle : undefined}
className={`relative w-full h-full p-2 text-sm rounded-md transition-all hover:opacity-80 ${
isFuture ? 'text-muted-foreground opacity-30 cursor-not-allowed' : 'cursor-pointer shadow-sm'
} ${isToday ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-background' : ''}`}
className={`relative w-full h-full p-2 text-sm rounded-md transition-all hover:opacity-80 ${isFuture ? 'text-muted-foreground opacity-30 cursor-not-allowed' : 'cursor-pointer shadow-sm'
} ${isToday ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-background' : ''}`}
onClick={() => !isFuture && handleDateSelect(date)}
disabled={isFuture}
>
@ -232,7 +209,7 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
const calendarBackground = theme === 'light'
? 'linear-gradient(135deg, rgba(20, 20, 30, 0.95) 0%, rgba(30, 30, 45, 0.9) 100%)'
: undefined;
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(240, 240, 245, 0.9) 100%)';
return (
<>
@ -248,8 +225,8 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
className={`rounded-md border p-3 ${theme === 'light' ? 'text-white' : 'bg-background/50'}`}
style={theme === 'light' ? { background: calendarBackground } : undefined}
className={`rounded-md border p-3 ${theme === 'light' ? 'text-white' : 'text-slate-900 bg-background/50'}`}
style={{ background: calendarBackground }}
showOutsideDays={false}
components={{
DayButton: CustomDayButton,
@ -261,28 +238,11 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
/>
</div>
{/* Daily Quote */}
<div
className="flex-1 flex flex-col justify-center p-5 rounded-xl border border-indigo-500/40 min-h-[120px] relative overflow-hidden"
style={{
background: 'linear-gradient(135deg, rgba(67, 56, 202, 0.35) 0%, rgba(109, 40, 217, 0.3) 50%, rgba(76, 29, 149, 0.4) 100%)',
boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 4px 20px rgba(99, 102, 241, 0.15)'
}}
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-purple-500/20 to-transparent rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="relative z-10">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="h-4 w-4 text-yellow-300 animate-pulse-subtle" />
<span className="text-xs font-semibold text-white/80 uppercase tracking-wider">Daily Inspiration</span>
</div>
<p className="text-sm font-medium text-white leading-relaxed mb-3 text-shadow-sm">
&ldquo;{dailyQuote.text}&rdquo;
</p>
<p className="text-xs text-white/70 font-medium">
{dailyQuote.author}
</p>
</div>
</div>
{/* Daily Inspiration */}
<DailyInspirationCard
initialReligion={religion}
onReligionChange={onReligionUpdate}
/>
</div>
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 md:flex md:flex-wrap gap-3 text-sm">

View File

@ -18,18 +18,19 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { User } from '@/lib/session';
import { fetchPreferences, fetchReminderSettings, saveReminderSettings, ReminderSettings } from '@/lib/storage';
import { fetchPreferences, fetchReminderSettings, saveReminderSettings, ReminderSettings, UserPreferences } from '@/lib/storage';
import { useNotifications } from '@/hooks/useNotifications';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon, Bell, BellOff, BellRing } from 'lucide-react';
import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon, Bell, BellOff, BellRing, Menu } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
interface UserHeaderProps {
user: User;
preferences?: UserPreferences | null;
}
export function UserHeader({ user }: UserHeaderProps) {
export function UserHeader({ user, preferences }: UserHeaderProps) {
const [userName, setUserName] = useState<string | null>(null);
const [reminderSettings, setReminderSettings] = useState<ReminderSettings>({ enabled: false, reminderTime: '09:00' });
const [showReminderDialog, setShowReminderDialog] = useState(false);
@ -40,16 +41,22 @@ export function UserHeader({ user }: UserHeaderProps) {
useEffect(() => {
const loadData = async () => {
// If preferences passed from parent, use them. Otherwise fetch.
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
const [prefs, reminders] = await Promise.all([
fetchPreferences(),
preferences ? Promise.resolve(preferences) : fetchPreferences(),
fetchReminderSettings(),
]);
setUserName(prefs.userName);
if (prefs) {
setUserName(prefs.userName);
}
setReminderSettings(reminders);
setLocalTime(reminders.reminderTime);
};
loadData();
}, []);
}, [preferences]);
const handleToggleReminders = async () => {
if (!reminderSettings.enabled && permission !== 'granted') {
@ -82,8 +89,10 @@ export function UserHeader({ user }: UserHeaderProps) {
};
return (
<header className="sticky top-0 z-50 border-b border-white/10" style={{
background: 'linear-gradient(135deg, rgba(10, 10, 20, 0.98) 0%, rgba(20, 30, 60, 0.95) 50%, rgba(15, 25, 50, 0.98) 100%)',
<header className="sticky top-0 z-50 border-b border-border/10 transition-colors duration-300" style={{
background: theme === 'light'
? 'rgba(255, 255, 255, 0.8)'
: 'linear-gradient(135deg, rgba(10, 10, 20, 0.98) 0%, rgba(20, 30, 60, 0.95) 50%, rgba(15, 25, 50, 0.98) 100%)',
backdropFilter: 'blur(10px)',
}}>
<div className="container mx-auto px-4 py-3 sm:py-4 flex items-center justify-between">
@ -101,7 +110,7 @@ export function UserHeader({ user }: UserHeaderProps) {
QuitTraq
</h1>
{userName && (
<p className="text-white/90 text-lg hidden sm:block ml-4">
<p className="text-foreground/90 text-lg hidden sm:block ml-4">
Welcome {userName}, you got this!
</p>
)}
@ -110,23 +119,22 @@ export function UserHeader({ user }: UserHeaderProps) {
<div className="flex items-center gap-2 sm:gap-3">
<button
onClick={() => setShowReminderDialog(true)}
className={`p-2.5 sm:p-2 rounded-full transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-white/30 hover:scale-110 active:scale-95 ${
reminderSettings.enabled
? 'bg-indigo-500/30 hover:bg-indigo-500/40'
: 'bg-white/10 hover:bg-white/20'
}`}
className={`p-2.5 sm:p-2 rounded-full transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-white/30 hover:scale-110 active:scale-95 ${reminderSettings.enabled
? 'bg-indigo-500/30 hover:bg-indigo-500/40'
: 'bg-muted hover:bg-muted/80'
}`}
aria-label="Reminder settings"
title={reminderSettings.enabled ? `Reminders on at ${reminderSettings.reminderTime}` : 'Reminders off'}
>
{reminderSettings.enabled ? (
<BellRing className="h-5 w-5 text-indigo-300 transition-transform duration-300" />
) : (
<Bell className="h-5 w-5 text-white/70 transition-transform duration-300" />
<Bell className="h-5 w-5 text-muted-foreground transition-transform duration-300" />
)}
</button>
<button
onClick={toggleTheme}
className="p-2.5 sm:p-2 rounded-full bg-white/10 hover:bg-white/20 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-white/30 hover:scale-110 active:scale-95"
className="p-2.5 sm:p-2 rounded-full bg-muted hover:bg-muted/80 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-primary/30 hover:scale-110 active:scale-95"
aria-label="Toggle theme"
>
{theme === 'dark' ? (
@ -135,19 +143,20 @@ export function UserHeader({ user }: UserHeaderProps) {
<Sun className="h-5 w-5 text-yellow-400 transition-transform duration-300" />
)}
</button>
{/* Main Navigation Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 px-3 py-2 rounded-full bg-white/10 hover:bg-white/20 transition-all focus:outline-none focus:ring-2 focus:ring-white/30">
<Avatar className="h-8 w-8 ring-2 ring-white/30">
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
<AvatarFallback className="bg-white/20 text-white text-sm">{initials}</AvatarFallback>
</Avatar>
<ChevronDown className="h-4 w-4 text-white" />
<button
className="p-2.5 sm:p-2 rounded-full bg-muted hover:bg-muted/80 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-primary/30 hover:scale-110 active:scale-95"
aria-label="Open menu"
>
<Menu className="h-5 w-5 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8}>
<DropdownMenuItem onClick={() => handleNavigate('/')}>
<Home className="mr-3 h-4 w-4 text-white/70" />
<Home className="mr-3 h-4 w-4 text-muted-foreground" />
<span>Dashboard</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
@ -159,7 +168,21 @@ export function UserHeader({ user }: UserHeaderProps) {
<Leaf className="mr-3 h-4 w-4 text-green-400" />
<span>Track Marijuana Usage</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 px-3 py-2 rounded-full bg-muted hover:bg-muted/80 transition-all focus:outline-none focus:ring-2 focus:ring-primary/30">
<Avatar className="h-8 w-8 ring-2 ring-primary/30">
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{initials}</AvatarFallback>
</Avatar>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8}>
<DropdownMenuItem onClick={handleLogout} className="text-red-400 hover:text-red-300">
<LogOut className="mr-3 h-4 w-4" />
<span>Sign out</span>
@ -170,7 +193,7 @@ export function UserHeader({ user }: UserHeaderProps) {
</div>
{userName && (
<div className="sm:hidden container mx-auto px-4 pb-2">
<p className="text-white/90 text-sm">
<p className="text-muted-foreground text-sm">
Welcome {userName}, you got this!
</p>
</div>
@ -187,19 +210,7 @@ export function UserHeader({ user }: UserHeaderProps) {
</DialogHeader>
<div className="space-y-4 py-4">
{/* Permission Status */}
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<span className="text-sm text-muted-foreground">Notifications</span>
<span className={`text-sm font-medium ${
!isSupported ? 'text-red-400' :
permission === 'granted' ? 'text-green-400' :
permission === 'denied' ? 'text-red-400' : 'text-yellow-400'
}`}>
{!isSupported ? 'Not supported' :
permission === 'granted' ? 'Enabled' :
permission === 'denied' ? 'Blocked' : 'Not set'}
</span>
</div>
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
@ -215,15 +226,13 @@ export function UserHeader({ user }: UserHeaderProps) {
</div>
<button
onClick={handleToggleReminders}
disabled={!isSupported || permission === 'denied'}
className={`relative w-12 h-6 rounded-full transition-all duration-300 ${
reminderSettings.enabled ? 'bg-indigo-500' : 'bg-muted-foreground/30'
} ${!isSupported || permission === 'denied' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
disabled={!isSupported || (permission === 'denied' && !reminderSettings.enabled)}
className={`relative w-12 h-6 rounded-full transition-all duration-300 ${reminderSettings.enabled ? 'bg-indigo-500' : 'bg-muted-foreground/30'
} ${!isSupported || (permission === 'denied' && !reminderSettings.enabled) ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<div
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all duration-300 ${
reminderSettings.enabled ? 'left-7' : 'left-1'
}`}
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all duration-300 ${reminderSettings.enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>

147
src/hooks/use-storage.ts Normal file
View File

@ -0,0 +1,147 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
getUserData,
saveUserData,
getUsageLogs,
addUsageLog,
getRecentLogs,
calculateStats,
getQuitPlan,
generateQuitPlan,
getCurrentWeek,
canGeneratePlan,
needsCheckIn as checkNeedsCheckIn,
} from "@/lib/storage";
import { UserData, UsageLog, QuitPlan, SubstanceType } from "@/types";
export function useUserData() {
const [userData, setUserData] = useState<UserData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setUserData(getUserData());
setIsLoading(false);
}, []);
const updateUserData = useCallback((data: Partial<UserData>) => {
const updated = saveUserData(data);
setUserData(updated);
return updated;
}, []);
const completeOnboarding = useCallback(
(substanceType: SubstanceType, stayLoggedIn: boolean) => {
return updateUserData({
substanceType,
stayLoggedIn,
onboardingComplete: true,
});
},
[updateUserData]
);
return {
userData,
isLoading,
updateUserData,
completeOnboarding,
isOnboardingComplete: userData?.onboardingComplete ?? false,
};
}
export function useUsageLogs() {
const [logs, setLogs] = useState<UsageLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const refreshLogs = useCallback(() => {
setLogs(getUsageLogs());
}, []);
useEffect(() => {
refreshLogs();
setIsLoading(false);
}, [refreshLogs]);
const logUsage = useCallback(
(puffs: number, date?: string) => {
const newLog = addUsageLog(puffs, date);
refreshLogs();
return newLog;
},
[refreshLogs]
);
const getRecent = useCallback((days: number = 7) => {
return getRecentLogs(days);
}, []);
const stats = calculateStats(getRecentLogs(7));
return {
logs,
isLoading,
logUsage,
getRecent,
refreshLogs,
stats,
};
}
export function useQuitPlan() {
const [plan, setPlan] = useState<QuitPlan | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshPlan = useCallback(() => {
setPlan(getQuitPlan());
}, []);
useEffect(() => {
refreshPlan();
setIsLoading(false);
}, [refreshPlan]);
const createPlan = useCallback(() => {
const recentLogs = getRecentLogs(7);
const { averagePuffs } = calculateStats(recentLogs);
if (averagePuffs === 0) {
return null;
}
const newPlan = generateQuitPlan(averagePuffs);
setPlan(newPlan);
return newPlan;
}, []);
const currentWeek = plan ? getCurrentWeek(plan) : 0;
const { canGenerate, daysTracked } = canGeneratePlan();
return {
plan,
isLoading,
createPlan,
refreshPlan,
currentWeek,
canGenerate,
daysTracked,
};
}
export function useCheckIn() {
const [needsCheckIn, setNeedsCheckIn] = useState(false);
useEffect(() => {
setNeedsCheckIn(checkNeedsCheckIn());
}, []);
const markCheckedIn = useCallback(() => {
setNeedsCheckIn(false);
}, []);
return {
needsCheckIn,
markCheckedIn,
};
}

View File

@ -15,6 +15,9 @@ export interface UserPreferences {
quitPlan: QuitPlan | null;
userName: string | null;
userAge: number | null;
religion: 'christian' | 'muslim' | 'jewish' | 'secular' | null;
lastNicotineUsageTime?: string | null; // ISO timestamp of last usage
lastWeedUsageTime?: string | null; // ISO timestamp of last usage
}
export interface QuitPlan {
@ -95,6 +98,7 @@ const defaultPreferences: UserPreferences = {
quitPlan: null,
userName: null,
userAge: null,
religion: null,
};
// Cache for preferences and usage data to avoid excessive API calls
@ -123,6 +127,7 @@ export function getCurrentUserId(): string | null {
// Async API functions
export async function fetchPreferences(): Promise<UserPreferences> {
if (preferencesCache) return preferencesCache;
try {
const response = await fetch('/api/preferences');
if (!response.ok) {
@ -154,6 +159,7 @@ export async function savePreferencesAsync(preferences: UserPreferences): Promis
}
export async function fetchUsageData(): Promise<UsageEntry[]> {
if (usageDataCache) return usageDataCache;
try {
const response = await fetch('/api/usage');
if (!response.ok) {
@ -216,6 +222,7 @@ export async function clearDayDataAsync(
// ============ ACHIEVEMENTS FUNCTIONS ============
export async function fetchAchievements(): Promise<Achievement[]> {
if (achievementsCache) return achievementsCache;
try {
const response = await fetch('/api/achievements');
if (!response.ok) return [];
@ -264,6 +271,7 @@ export function getAchievements(): Achievement[] {
// ============ REMINDERS FUNCTIONS ============
export async function fetchReminderSettings(): Promise<ReminderSettings> {
if (reminderSettingsCache) return reminderSettingsCache;
try {
const response = await fetch('/api/reminders');
if (!response.ok) return { enabled: false, reminderTime: '09:00' };
@ -298,6 +306,7 @@ export function getReminderSettings(): ReminderSettings {
// ============ SAVINGS FUNCTIONS ============
export async function fetchSavingsConfig(): Promise<SavingsConfig | null> {
if (savingsConfigCache) return savingsConfigCache;
try {
const response = await fetch('/api/savings');
if (!response.ok) return null;
@ -386,36 +395,7 @@ export function calculateTotalSaved(
return Math.max(0, expectedSpend - actualSpend);
}
export function getMinutesSinceQuit(
usageData: UsageEntry[],
substance: 'nicotine' | 'weed'
): number {
// Find the last usage date for this substance
const substanceData = usageData
.filter((e) => e.substance === substance && e.count > 0)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (substanceData.length === 0) {
// No usage recorded, assume they just started
return 0;
}
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
const lastUsageDateStr = substanceData[0].date;
// If the last usage was today, reset to 0 (just used)
if (lastUsageDateStr === todayStr) {
return 0;
}
// For past days, count from the end of that day
const lastUsageDate = new Date(lastUsageDateStr);
lastUsageDate.setHours(23, 59, 59, 999);
const diffMs = now.getTime() - lastUsageDate.getTime();
return Math.max(0, Math.floor(diffMs / (1000 * 60)));
}
export function checkBadgeEligibility(
badgeId: string,

View File

@ -21,6 +21,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
}, []);
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, [theme]);
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);

View File

@ -0,0 +1,77 @@
import { RecoveryTracker } from './RecoveryTracker';
export class NicotineTracker extends RecoveryTracker {
calculateMinutesFree(precise: boolean = false): number {
// 1. Try to use precise timestamp from preferences first
if (this.preferences?.lastNicotineUsageTime) {
const now = new Date();
const lastUsageTime = new Date(this.preferences.lastNicotineUsageTime);
console.log('[NicotineTracker] Found timestamp:', this.preferences.lastNicotineUsageTime);
const diffMs = now.getTime() - lastUsageTime.getTime();
const minutes = this.msToMinutes(diffMs, precise);
// Validation: Ensure the timestamp aligns with the last recorded usage date.
// If the user manually edited usage for a LATER date, the timestamp might be stale.
const lastRecordedDateStr = this.getLastRecordedDate();
console.log('[NicotineTracker] Last recorded date:', lastRecordedDateStr);
if (lastRecordedDateStr) {
const lastRecordedDate = new Date(lastRecordedDateStr);
lastRecordedDate.setHours(0, 0, 0, 0);
// If the timestamp is older than the start of the last recorded day,
// it means we have a newer manual entry without a timestamp.
// In this case, fall back to date-based logic.
if (lastUsageTime < lastRecordedDate) {
console.log('[NicotineTracker] Timestamp is stale, falling back to date logic');
return this.calculateDateBasedMinutes(lastRecordedDateStr, precise);
}
}
return minutes;
}
// 2. Fallback to date-based logic if no timestamp exists
const lastDateStr = this.getLastRecordedDate();
// 3. If no nicotine usage ever recorded, use tracking start date
if (!lastDateStr) {
if (this.preferences?.trackingStartDate) {
const startDate = new Date(this.preferences.trackingStartDate);
startDate.setHours(0, 0, 0, 0); // Count from start of tracking day
const now = new Date();
const diffMs = now.getTime() - startDate.getTime();
return this.msToMinutes(diffMs, precise);
}
return 0; // No usage and no tracking start date
}
return this.calculateDateBasedMinutes(lastDateStr, precise);
}
private getLastRecordedDate(): string | null {
const nicotineData = this.usageData
.filter((e) => e.substance === 'nicotine' && e.count > 0)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return nicotineData.length > 0 ? nicotineData[0].date : null;
}
private calculateDateBasedMinutes(dateStr: string, precise: boolean): number {
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
// If the last usage was today but we have no timestamp, reset to 0
if (dateStr === todayStr) {
return 0;
}
// For past days, count from the END of that day (23:59:59)
const lastUsageDate = new Date(dateStr);
lastUsageDate.setHours(23, 59, 59, 999);
const diffMs = now.getTime() - lastUsageDate.getTime();
return this.msToMinutes(diffMs, precise);
}
}

View File

@ -0,0 +1,33 @@
import { UsageEntry, UserPreferences } from '../storage';
export abstract class RecoveryTracker {
protected usageData: UsageEntry[];
protected preferences: UserPreferences | null;
constructor(usageData: UsageEntry[], preferences: UserPreferences | null) {
this.usageData = usageData;
this.preferences = preferences;
}
/**
* Calculates the number of minutes elapsed since the last usage.
* This is the core logic that subclasses must support, but the implementation
* heavily depends on the specific substance's data source (preferences timestamp vs usage logs).
*/
abstract calculateMinutesFree(precise?: boolean): number;
/**
* Helper to convert milliseconds to minutes with optional precision.
*/
protected msToMinutes(ms: number, precise: boolean = false): number {
const minutes = Math.max(0, ms / (1000 * 60));
return precise ? minutes : Math.floor(minutes);
}
/**
* Helper to check if a timestamp is valid and recent enough to rely on.
*/
protected isValidTimestamp(timestamp: string | null | undefined): boolean {
return !!timestamp && !isNaN(new Date(timestamp).getTime());
}
}

View File

@ -0,0 +1,69 @@
import { RecoveryTracker } from './RecoveryTracker';
export class WeedTracker extends RecoveryTracker {
calculateMinutesFree(precise: boolean = false): number {
// 1. Try to use precise timestamp from preferences first
if (this.preferences?.lastWeedUsageTime) {
const now = new Date();
const lastUsageTime = new Date(this.preferences.lastWeedUsageTime);
const diffMs = now.getTime() - lastUsageTime.getTime();
const minutes = this.msToMinutes(diffMs, precise);
// Validation: Ensure the timestamp aligns with the last recorded usage date.
const lastRecordedDateStr = this.getLastRecordedDate();
if (lastRecordedDateStr) {
const lastRecordedDate = new Date(lastRecordedDateStr);
lastRecordedDate.setHours(0, 0, 0, 0);
if (lastUsageTime < lastRecordedDate) {
return this.calculateDateBasedMinutes(lastRecordedDateStr, precise);
}
}
return minutes;
}
// 2. Fallback to date-based logic if no timestamp exists
const lastDateStr = this.getLastRecordedDate();
// 3. If no weed usage ever recorded, use tracking start date
if (!lastDateStr) {
if (this.preferences?.trackingStartDate) {
const startDate = new Date(this.preferences.trackingStartDate);
startDate.setHours(0, 0, 0, 0); // Count from start of tracking day
const now = new Date();
const diffMs = now.getTime() - startDate.getTime();
return this.msToMinutes(diffMs, precise);
}
return 0; // No usage and no tracking start date
}
return this.calculateDateBasedMinutes(lastDateStr, precise);
}
private getLastRecordedDate(): string | null {
const weedData = this.usageData
.filter((e) => e.substance === 'weed' && e.count > 0)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return weedData.length > 0 ? weedData[0].date : null;
}
private calculateDateBasedMinutes(dateStr: string, precise: boolean): number {
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
// If the last usage was today but we have no timestamp, reset to 0
if (dateStr === todayStr) {
return 0;
}
// For past days, count from the END of that day (23:59:59)
const lastUsageDate = new Date(dateStr);
lastUsageDate.setHours(23, 59, 59, 999);
const diffMs = now.getTime() - lastUsageDate.getTime();
return this.msToMinutes(diffMs, precise);
}
}

9
src/proxy 2.ts Normal file
View File

@ -0,0 +1,9 @@
import { authkitMiddleware } from "@workos-inc/authkit-nextjs";
export const proxy = authkitMiddleware();
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};