Add dark/light theme toggle with adaptive styling

Implement theme context provider with localStorage persistence and add
toggle button to header. Update Dashboard, StatsCard, QuitPlanCard,
SubstanceTrackingPage, and UsageCalendar components with theme-aware
gradients and colors. Also add daily inspirational quotes to calendar
and fix usage prompt to only show once per day.
This commit is contained in:
Avery Felts 2026-01-24 03:17:09 -07:00
parent ec0d83586d
commit fac443c281
11 changed files with 239 additions and 40 deletions

View File

@ -197,4 +197,9 @@
pointer-events: none;
z-index: -1;
}
/* Calendar styling - reduce overall size */
.rdp {
--rdp-cell-size: 36px;
}
}

View File

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "@/components/Providers";
export const metadata: Metadata = {
title: "QuitTraq - Track Your Journey to Quit Smoking",
@ -19,7 +20,7 @@ export default function RootLayout({
<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}
<Providers>{children}</Providers>
</body>
</html>
);

View File

@ -8,6 +8,7 @@ import {
savePreferencesAsync,
saveUsageEntryAsync,
shouldShowUsagePrompt,
markPromptShown,
generateQuitPlan,
UserPreferences,
UsageEntry,
@ -20,6 +21,7 @@ import { StatsCard } from './StatsCard';
import { QuitPlanCard } from './QuitPlanCard';
import { Button } from '@/components/ui/button';
import { PlusCircle } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
interface DashboardProps {
user: User;
@ -32,6 +34,7 @@ export function Dashboard({ user }: DashboardProps) {
const [showUsagePrompt, setShowUsagePrompt] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const { theme } = useTheme();
const loadData = useCallback(async () => {
const [prefs, usage] = await Promise.all([
@ -52,6 +55,7 @@ export function Dashboard({ user }: DashboardProps) {
setShowSetup(true);
} else if (shouldShowUsagePrompt()) {
setShowUsagePrompt(true);
markPromptShown();
}
setIsLoading(false);
@ -121,8 +125,12 @@ 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">
<div className="min-h-screen" style={{ background: pageBackground }}>
<UserHeader user={user} />
<main className="container mx-auto px-4 py-8">

View File

@ -0,0 +1,8 @@
'use client';
import { ThemeProvider } from '@/lib/theme-context';
import { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}

View File

@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button';
import { QuitPlan, UsageEntry } from '@/lib/storage';
import { Target, TrendingDown } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
interface QuitPlanCardProps {
plan: QuitPlan | null;
@ -16,6 +17,8 @@ export function QuitPlanCard({
onGeneratePlan,
usageData,
}: QuitPlanCardProps) {
const { theme } = useTheme();
// Count unique days with any logged data
const uniqueDaysWithData = new Set(usageData.map(e => e.date)).size;
const daysRemaining = Math.max(0, 7 - uniqueDaysWithData);
@ -25,10 +28,20 @@ export function QuitPlanCard({
const totalUsage = usageData.reduce((sum, e) => sum + e.count, 0);
const currentAverage = uniqueDaysWithData > 0 ? Math.round(totalUsage / uniqueDaysWithData) : 0;
// Yellow gradient for tracking phase (darker in light mode)
const yellowBackground = theme === 'light'
? 'linear-gradient(135deg, rgba(161, 98, 7, 0.85) 0%, rgba(133, 77, 14, 0.9) 100%)'
: 'linear-gradient(135deg, rgba(234, 179, 8, 0.2) 0%, rgba(202, 138, 4, 0.15) 100%)';
// Pink gradient for active plan (darker in light mode)
const pinkBackground = theme === 'light'
? 'linear-gradient(135deg, rgba(157, 23, 77, 0.85) 0%, rgba(131, 24, 67, 0.9) 100%)'
: 'linear-gradient(135deg, rgba(236, 72, 153, 0.2) 0%, rgba(219, 39, 119, 0.15) 100%)';
if (!plan) {
return (
<Card className="backdrop-blur-sm shadow-lg drop-shadow-md border-yellow-500/30" style={{
background: 'linear-gradient(135deg, rgba(234, 179, 8, 0.2) 0%, rgba(202, 138, 4, 0.15) 100%)'
background: yellowBackground
}}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
@ -94,7 +107,7 @@ export function QuitPlanCard({
return (
<Card className="backdrop-blur-sm shadow-lg drop-shadow-md border-pink-500/30" style={{
background: 'linear-gradient(135deg, rgba(236, 72, 153, 0.2) 0%, rgba(219, 39, 119, 0.15) 100%)'
background: pinkBackground
}}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">

View File

@ -3,6 +3,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UsageEntry } from '@/lib/storage';
import { Cigarette, Leaf } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
interface StatsCardProps {
usageData: UsageEntry[];
@ -10,6 +11,7 @@ interface StatsCardProps {
}
export function StatsCard({ usageData, substance }: StatsCardProps) {
const { theme } = useTheme();
const substanceData = usageData.filter((e) => e.substance === substance);
// Calculate stats
@ -54,12 +56,21 @@ export function StatsCard({ usageData, substance }: StatsCardProps) {
const unitLabel = substance === 'nicotine' ? 'puffs' : 'hits';
const iconColor = substance === 'nicotine' ? 'text-red-400' : 'text-green-400';
const borderColor = substance === 'nicotine' ? 'border-red-500/30' : 'border-green-500/30';
const bgGradient = substance === 'nicotine'
? 'from-red-500/20 to-red-900/10'
: 'from-green-500/20 to-green-900/10';
// Use darker gradients in light mode for better contrast
const cardBackground = theme === 'light'
? substance === 'nicotine'
? 'linear-gradient(135deg, rgba(185, 28, 28, 0.85) 0%, rgba(127, 29, 29, 0.9) 100%)'
: 'linear-gradient(135deg, rgba(22, 101, 52, 0.85) 0%, rgba(20, 83, 45, 0.9) 100%)'
: substance === 'nicotine'
? 'linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(127, 29, 29, 0.1) 100%)'
: 'linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(20, 83, 45, 0.1) 100%)';
return (
<Card className={`bg-card/80 backdrop-blur-sm border ${borderColor} bg-gradient-to-br ${bgGradient} shadow-lg drop-shadow-md`}>
<Card
className={`backdrop-blur-sm border ${borderColor} shadow-lg drop-shadow-md`}
style={{ background: cardBackground }}
>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-white">
<SubstanceIcon className={`h-5 w-5 ${iconColor}`} />

View File

@ -7,6 +7,7 @@ import { UserHeader } from './UserHeader';
import { StatsCard } from './StatsCard';
import { UsageTrendGraph } from './UsageTrendGraph';
import { Cigarette, Leaf } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
interface SubstanceTrackingPageProps {
user: User;
@ -16,6 +17,7 @@ interface SubstanceTrackingPageProps {
export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPageProps) {
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { theme } = useTheme();
const loadData = useCallback(async () => {
const usage = await fetchUsageData();
@ -50,8 +52,12 @@ export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPage
);
}
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">
<div className="min-h-screen" style={{ background: pageBackground }}>
<UserHeader user={user} />
<main className="container mx-auto px-4 py-8">
@ -62,8 +68,8 @@ export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPage
<SubstanceIcon className="h-8 w-8" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">{substanceLabel} Tracking</h1>
<p className="text-white/70 mt-1">Monitor your {substanceLabel.toLowerCase()} usage and progress</p>
<h1 className={`text-3xl font-bold ${theme === 'light' ? 'text-gray-900' : 'text-white'}`}>{substanceLabel} Tracking</h1>
<p className={`mt-1 ${theme === 'light' ? 'text-gray-700' : 'text-white/70'}`}>Monitor your {substanceLabel.toLowerCase()} usage and progress</p>
</div>
</div>
</div>
@ -71,11 +77,11 @@ export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPage
{/* Today's Status Message */}
<div className="mb-8 text-center">
{todayCount === 0 ? (
<p className="text-2xl font-medium text-green-400">
<p className={`text-2xl font-medium ${theme === 'light' ? 'text-green-600' : 'text-green-400'}`}>
Great job, nothing yet!
</p>
) : (
<p className="text-2xl font-medium text-white">
<p className={`text-2xl font-medium ${theme === 'light' ? 'text-gray-900' : 'text-white'}`}>
{todayCount} {todayCount === 1 ? (substance === 'nicotine' ? 'puff' : 'hit') : unitLabel} recorded, you got this!
</p>
)}
@ -83,7 +89,7 @@ export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPage
{/* Inspirational Message */}
<div className="mb-8 text-center">
<p className="text-xl font-light text-white/60 italic">
<p className={`text-xl font-light italic ${theme === 'light' ? 'text-gray-500' : 'text-white/60'}`}>
&quot;One day at a time...&quot;
</p>
</div>

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { DayPicker, DayButtonProps } from 'react-day-picker';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -13,7 +13,42 @@ 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 { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf } from 'lucide-react';
import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf, Sparkles } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
const quotes = [
{ text: "The secret of getting ahead is getting started.", author: "Mark Twain" },
{ text: "It does not matter how slowly you go as long as you do not stop.", author: "Confucius" },
{ text: "Your future self will thank you for the choices you make today.", author: "Unknown" },
{ text: "Every moment is a fresh beginning.", author: "T.S. Eliot" },
{ text: "Believe you can and you're halfway there.", author: "Theodore Roosevelt" },
{ text: "Small steps every day lead to big changes over time.", author: "Unknown" },
{ text: "You are stronger than your cravings.", author: "Unknown" },
{ text: "The best time to plant a tree was 20 years ago. The second best time is now.", author: "Chinese Proverb" },
{ text: "Progress, not perfection, is what we should be asking of ourselves.", author: "Julia Cameron" },
{ text: "The greatest glory in living lies not in never falling, but in rising every time we fall.", author: "Nelson Mandela" },
{ text: "Your life does not get better by chance, it gets better by change.", author: "Jim Rohn" },
{ text: "You don't have to be great to start, but you have to start to be great.", author: "Zig Ziglar" },
{ text: "The pain of discipline is far less than the pain of regret.", author: "Sarah Bombell" },
{ text: "One day or day one. You decide.", author: "Unknown" },
{ text: "Your health is an investment, not an expense.", author: "Unknown" },
{ text: "The comeback is always stronger than the setback.", author: "Unknown" },
{ text: "Difficult roads often lead to beautiful destinations.", author: "Zig Ziglar" },
{ text: "Success is the sum of small efforts repeated day in and day out.", author: "Robert Collier" },
{ text: "Fall seven times, stand up eight.", author: "Japanese Proverb" },
{ text: "Great things never come from comfort zones.", author: "Unknown" },
{ text: "Every champion was once a contender that refused to give up.", author: "Rocky Balboa" },
{ text: "Don't stop when you're tired. Stop when you're done.", author: "Unknown" },
{ text: "Break free from the chains of habit and unlock your true potential.", author: "Unknown" },
{ text: "Dream it. Wish it. Do it.", author: "Unknown" },
{ text: "Your limitation—it's only your imagination.", author: "Unknown" },
{ text: "You are not defined by your past. You are prepared by it.", author: "Unknown" },
{ text: "Don't let yesterday take up too much of today.", author: "Will Rogers" },
{ 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 only person you are destined to become is the person you decide to be.", author: "Ralph Waldo Emerson" },
{ text: "The only way to do great work is to love what you do.", author: "Steve Jobs" },
{ text: "The harder you work for something, the greater you'll feel when you achieve it.", author: "Unknown" },
];
interface UsageCalendarProps {
usageData: UsageEntry[];
@ -26,6 +61,17 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
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];
@ -102,9 +148,12 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
}
if (!hasNicotine && !hasWeed) {
// No usage - light blue hue
// No usage - lighter blue in light mode, standard blue in dark mode
const blueGradient = theme === 'light'
? 'linear-gradient(135deg, rgba(147, 197, 253, 0.7) 0%, rgba(96, 165, 250, 0.8) 100%)'
: 'linear-gradient(135deg, rgba(96, 165, 250, 0.5) 0%, rgba(59, 130, 246, 0.6) 100%)';
return {
background: 'linear-gradient(135deg, rgba(96, 165, 250, 0.5) 0%, rgba(59, 130, 246, 0.6) 100%)',
background: blueGradient,
color: 'white',
};
}
@ -132,7 +181,7 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
background: `linear-gradient(135deg, rgba(239, 68, 68, ${0.5 + intensity * 0.4}) 0%, rgba(185, 28, 28, ${0.6 + intensity * 0.4}) 100%)`,
color: 'white',
};
}, []);
}, [theme]);
const CustomDayButton = useCallback(({ day, modifiers, ...props }: DayButtonProps) => {
const date = day.date;
@ -176,6 +225,10 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
);
}, [usageData, getColorStyle]);
const calendarBackground = theme === 'light'
? 'linear-gradient(135deg, rgba(20, 20, 30, 0.95) 0%, rgba(30, 30, 45, 0.9) 100%)'
: undefined;
return (
<>
<Card className="bg-card/80 backdrop-blur-sm shadow-lg drop-shadow-md">
@ -183,20 +236,44 @@ export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
<CardTitle>Usage Calendar</CardTitle>
</CardHeader>
<CardContent>
<DayPicker
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
className="rounded-md border p-3 bg-background/50"
showOutsideDays={false}
components={{
DayButton: CustomDayButton,
Chevron: ({ orientation }) =>
orientation === 'left'
? <ChevronLeftIcon className="h-4 w-4" />
: <ChevronRightIcon className="h-4 w-4" />,
}}
/>
<div className="flex gap-4">
{/* Calendar */}
<div className="shrink-0">
<DayPicker
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}
showOutsideDays={false}
components={{
DayButton: CustomDayButton,
Chevron: ({ orientation }) =>
orientation === 'left'
? <ChevronLeftIcon className="h-4 w-4" />
: <ChevronRightIcon className="h-4 w-4" />,
}}
/>
</div>
{/* Daily Quote */}
<div
className="flex-1 flex flex-col justify-center p-4 rounded-lg border border-indigo-500/30"
style={{ background: 'linear-gradient(135deg, rgba(67, 56, 202, 0.4) 0%, rgba(109, 40, 217, 0.35) 50%, rgba(76, 29, 149, 0.45) 100%)' }}
>
<div className="flex items-center gap-2 mb-3">
<Sparkles className="h-4 w-4 text-yellow-300" />
<span className="text-xs font-medium text-white/70 uppercase tracking-wide">Daily Inspiration</span>
</div>
<p className="text-sm font-medium text-white leading-relaxed mb-3">
&ldquo;{dailyQuote.text}&rdquo;
</p>
<p className="text-xs text-white/60">
{dailyQuote.author}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ background: 'linear-gradient(135deg, rgba(96,165,250,0.5), rgba(59,130,246,0.6))' }} />

View File

@ -12,7 +12,8 @@ import { User } from '@/lib/session';
import { fetchPreferences } from '@/lib/storage';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Cigarette, Leaf, LogOut, Home, ChevronDown } from 'lucide-react';
import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
interface UserHeaderProps {
user: User;
@ -21,6 +22,7 @@ interface UserHeaderProps {
export function UserHeader({ user }: UserHeaderProps) {
const [userName, setUserName] = useState<string | null>(null);
const router = useRouter();
const { theme, toggleTheme } = useTheme();
useEffect(() => {
const loadUserName = async () => {
@ -44,12 +46,12 @@ export function UserHeader({ user }: UserHeaderProps) {
};
return (
<header className="border-b border-white/10" style={{
background: 'linear-gradient(135deg, rgba(10, 10, 20, 0.95) 0%, rgba(20, 30, 60, 0.9) 50%, rgba(15, 25, 50, 0.95) 100%)',
<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%)',
backdropFilter: 'blur(10px)',
}}>
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-8">
<h1
className="text-2xl font-bold text-white cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleNavigate('/')}
@ -57,13 +59,24 @@ export function UserHeader({ user }: UserHeaderProps) {
QuitTraq
</h1>
{userName && (
<p className="text-white/90 text-lg hidden sm:block">
<p className="text-white/90 text-lg hidden sm:block ml-4">
Welcome {userName}, you got this!
</p>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<button
onClick={toggleTheme}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-all focus:outline-none focus:ring-2 focus:ring-white/30"
aria-label="Toggle theme"
>
{theme === 'dark' ? (
<Moon className="h-5 w-5 text-blue-300" />
) : (
<Sun className="h-5 w-5 text-yellow-400" />
)}
</button>
<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">

View File

@ -191,8 +191,22 @@ export function clearDayData(
clearDayDataAsync(date, substance);
}
const LAST_PROMPT_KEY = 'quittraq_last_prompt_date';
export function shouldShowUsagePrompt(): boolean {
return true;
if (typeof window === 'undefined') return false;
const today = new Date().toISOString().split('T')[0];
const lastPromptDate = localStorage.getItem(LAST_PROMPT_KEY);
return lastPromptDate !== today;
}
export function markPromptShown(): void {
if (typeof window === 'undefined') return;
const today = new Date().toISOString().split('T')[0];
localStorage.setItem(LAST_PROMPT_KEY, today);
}
export function getWeeklyData(substance: 'nicotine' | 'weed', _userId?: string): UsageEntry[] {

43
src/lib/theme-context.tsx Normal file
View File

@ -0,0 +1,43 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
type Theme = 'dark' | 'light';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark');
useEffect(() => {
const saved = localStorage.getItem('theme') as Theme | null;
if (saved) {
setTheme(saved);
}
}, []);
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}