Refactor: New Log Usage UI with dynamic drop-up and scroll-wheel logger

This commit is contained in:
Avery Felts 2026-01-31 18:28:19 -07:00
parent 79377fb210
commit af6ac933ee
3 changed files with 296 additions and 12 deletions

View File

@ -25,7 +25,6 @@ import {
} from '@/lib/storage';
import { UserHeader } from './UserHeader';
import { SetupWizard } from './SetupWizard';
import { UsagePromptDialog } from './UsagePromptDialog';
import { UsageCalendar } from './UsageCalendar';
import { StatsCard } from './StatsCard';
import { UnifiedQuitPlanCard } from './UnifiedQuitPlanCard';
@ -34,8 +33,10 @@ import { CelebrationAnimation } from './CelebrationAnimation';
import { HealthTimelineCard } from './HealthTimelineCard';
import { SavingsTrackerCard } from './SavingsTrackerCard';
import { MoodTracker } from './MoodTracker';
import { ScrollWheelLogger } from './ScrollWheelLogger';
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
import { Button } from '@/components/ui/button';
import { PlusCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import { PlusCircle, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import { getTodayString } from '@/lib/date-utils';
@ -52,6 +53,8 @@ export function Dashboard({ user }: DashboardProps) {
const [showSetup, setShowSetup] = useState(false);
const [showUsagePrompt, setShowUsagePrompt] = useState(false);
const [showCelebration, setShowCelebration] = useState(false);
const [isSubstancePickerOpen, setIsSubstancePickerOpen] = useState(false);
const [activeLoggingSubstance, setActiveLoggingSubstance] = useState<'nicotine' | 'weed' | null>(null);
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
@ -224,6 +227,8 @@ export function Dashboard({ user }: DashboardProps) {
}
setShowUsagePrompt(false);
setActiveLoggingSubstance(null);
setIsSubstancePickerOpen(false);
// Reload data and force calendar refresh
const usage = await fetchUsageData();
setUsageData(usage);
@ -300,15 +305,27 @@ export function Dashboard({ user }: DashboardProps) {
<main className="container mx-auto px-4 py-4 sm:py-8 pb-4 sm:pb-8 max-w-full">
{preferences && (
<>
{/* Floating Log Button */}
{/* Floating Log Button - Simplified to toggle Picker */}
<div className={`fixed bottom-6 right-6 z-40 transition-all duration-300 ${isAnyModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
<Button
size="lg"
onClick={() => setShowUsagePrompt(true)}
className="h-14 px-6 sm:h-16 sm:px-8 text-base sm:text-lg rounded-full shadow-xl bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 drop-shadow-lg hover-lift transition-all duration-300 hover:scale-105 active:scale-95"
onClick={() => setIsSubstancePickerOpen(!isSubstancePickerOpen)}
className={`h-14 px-6 sm:h-16 sm:px-8 text-base sm:text-lg rounded-full shadow-xl transition-all duration-300 hover:scale-105 active:scale-95 flex items-center gap-2 ${isSubstancePickerOpen
? 'bg-white/10 text-white backdrop-blur-xl border border-white/20'
: 'bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 text-white border-none'
}`}
>
<PlusCircle className="mr-2 h-5 w-5 sm:h-6 sm:w-6" />
Log Usage
{isSubstancePickerOpen ? (
<>
<X className="h-5 w-5 sm:h-6 sm:w-6" />
Cancel
</>
) : (
<>
<PlusCircle className="h-5 w-5 sm:h-6 sm:w-6" />
Log Usage
</>
)}
</Button>
</div>
@ -440,13 +457,23 @@ export function Dashboard({ user }: DashboardProps) {
<SetupWizard open={showSetup} onComplete={handleSetupComplete} />
<UsagePromptDialog
open={showUsagePrompt}
onClose={() => setShowUsagePrompt(false)}
onSubmit={handleUsageSubmit}
userId={user.id}
<UsageLoggerDropUp
isOpen={isSubstancePickerOpen}
onSelect={(substance) => {
setActiveLoggingSubstance(substance);
setIsSubstancePickerOpen(false);
}}
onClose={() => setIsSubstancePickerOpen(false)}
/>
{activeLoggingSubstance && (
<ScrollWheelLogger
substance={activeLoggingSubstance}
onSubmit={handleUsageSubmit}
onCancel={() => setActiveLoggingSubstance(null)}
/>
)}
{showCelebration && newBadge && (
<CelebrationAnimation
badge={newBadge}

View File

@ -0,0 +1,186 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { X, Check, Keyboard } from 'lucide-react';
interface ScrollWheelLoggerProps {
substance: 'nicotine' | 'weed';
onSubmit: (count: number, substance: 'nicotine' | 'weed') => void;
onCancel: () => void;
}
export function ScrollWheelLogger({ substance, onSubmit, onCancel }: ScrollWheelLoggerProps) {
const [selectedValue, setSelectedValue] = useState(1);
const [showCustom, setShowCustom] = useState(false);
const [customValue, setCustomValue] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
const isNicotine = substance === 'nicotine';
const colors = isNicotine
? {
border: 'border-red-500/30',
bg: 'bg-red-500/5',
bgMuted: 'bg-red-500/10',
bgActive: 'bg-red-500/20',
bgSolid: 'bg-red-600',
bgSolidHover: 'hover:bg-red-500',
text: 'text-red-400',
dot: 'bg-red-500',
shadow: 'shadow-red-500/20'
}
: {
border: 'border-green-500/30',
bg: 'bg-green-500/5',
bgMuted: 'bg-green-500/10',
bgActive: 'bg-green-500/20',
bgSolid: 'bg-green-600',
bgSolidHover: 'hover:bg-green-500',
text: 'text-green-400',
dot: 'bg-green-500',
shadow: 'shadow-green-500/20'
};
const label = isNicotine ? 'Puffs/Cigarettes' : 'Hits';
// Generate values 1-100
const values = Array.from({ length: 100 }, (_, i) => i + 1);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
const container = scrollRef.current;
const itemHeight = 60; // Expected height of each item
const scrollTop = container.scrollTop;
const index = Math.round(scrollTop / itemHeight);
const value = values[Math.max(0, Math.min(index, values.length - 1))];
if (value !== selectedValue) {
setSelectedValue(value);
}
}, [selectedValue, values]);
const selectValue = (val: number) => {
if (!scrollRef.current) return;
const itemHeight = 60;
scrollRef.current.scrollTo({
top: (val - 1) * itemHeight,
behavior: 'smooth'
});
setSelectedValue(val);
};
const handleConfirm = () => {
const finalValue = showCustom ? parseInt(customValue, 10) : selectedValue;
if (!isNaN(finalValue) && finalValue > 0) {
onSubmit(finalValue, substance);
}
};
// Keyboard support for enter key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm();
} else if (e.key === 'Escape') {
onCancel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedValue, customValue, showCustom, substance]);
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onCancel}
/>
{/* Modal Container */}
<div className="relative w-full max-w-sm glass-card overflow-hidden shadow-2xl animate-in slide-in-from-bottom duration-300">
<div className={`p-4 border-b border-white/10 flex items-center justify-between ${colors.bgMuted}`}>
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${colors.dot} animate-pulse`} />
<h3 className="font-black uppercase tracking-widest text-sm opacity-80">
Log {substance}
</h3>
</div>
<button
onClick={onCancel}
className="p-1 hover:bg-white/10 rounded-full transition-colors"
>
<X className="w-5 h-5 opacity-50" />
</button>
</div>
<div className="p-8 flex flex-col items-center">
<p className="text-sm opacity-60 mb-6 uppercase tracking-tighter font-bold">
Select {label}
</p>
{!showCustom ? (
<div className="relative h-[180px] w-full flex items-center justify-center overflow-hidden">
{/* Selection Highlight */}
<div className={`absolute top-1/2 -translate-y-1/2 w-full h-[60px] border-y ${colors.border} ${colors.bg}`} />
{/* Scroll Area */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="w-full h-full overflow-y-scroll snap-y snap-mandatory no-scrollbar py-[60px]"
>
{values.map((val) => (
<div
key={val}
onClick={() => selectValue(val)}
className={`h-[60px] flex items-center justify-center snap-center cursor-pointer transition-all duration-300 ${selectedValue === val
? `text-4xl font-black ${colors.text} scale-110`
: 'text-lg opacity-20 scale-90'
}`}
>
{val}
</div>
))}
</div>
</div>
) : (
<div className="h-[180px] w-full flex flex-col items-center justify-center gap-4">
<Input
type="number"
placeholder="0"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
autoFocus
className={`text-center text-5xl h-24 font-black bg-transparent border-none focus-visible:ring-0 ${colors.text} placeholder:opacity-10`}
/>
<p className="text-xs opacity-40">Enter custom amount</p>
</div>
)}
<div className="mt-8 flex items-center gap-4 w-full">
<Button
variant="ghost"
size="icon"
onClick={() => {
setShowCustom(!showCustom);
if (!showCustom) setCustomValue(selectedValue.toString());
}}
className={`rounded-full h-12 w-12 border border-white/5 transition-all ${showCustom ? `${colors.bgActive} ${colors.text} ${colors.border}` : 'hover:bg-white/5'}`}
>
<Keyboard className="w-5 h-5 " />
</Button>
<Button
onClick={handleConfirm}
className={`flex-1 h-12 rounded-full font-black uppercase tracking-widest shadow-lg ${colors.shadow} transition-all hover:scale-[1.02] active:scale-95 ${colors.bgSolid} ${colors.bgSolidHover} text-white border-none`}
>
<Check className="w-5 h-5 mr-2" />
Log {showCustom ? customValue || '?' : selectedValue}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
'use client';
import { Button } from '@/components/ui/button';
import { Cigarette, Leaf, X } from 'lucide-react';
import { useEffect } from 'react';
interface UsageLoggerDropUpProps {
isOpen: boolean;
onSelect: (substance: 'nicotine' | 'weed') => void;
onClose: () => void;
}
export function UsageLoggerDropUp({ isOpen, onSelect, onClose }: UsageLoggerDropUpProps) {
// Close on Escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-40">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/20 backdrop-blur-[2px] transition-opacity animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Container for Buttons */}
<div className="absolute bottom-24 right-6 flex flex-col items-end gap-3 z-50">
{/* Nicotine Button */}
<div className="animate-in slide-in-from-bottom-4 fade-in duration-300 fill-mode-both" style={{ animationDelay: '50ms' }}>
<Button
size="lg"
onClick={() => onSelect('nicotine')}
className="h-14 px-6 rounded-full shadow-xl bg-gradient-to-r from-red-600 to-red-500 hover:from-red-500 hover:to-red-400 text-white border-none font-bold uppercase tracking-widest flex items-center gap-3 transition-all hover:scale-105 active:scale-95"
>
<Cigarette className="h-5 w-5" />
<span>Nicotine</span>
</Button>
</div>
{/* Marijuana Button */}
<div className="animate-in slide-in-from-bottom-4 fade-in duration-300 fill-mode-both" style={{ animationDelay: '150ms' }}>
<Button
size="lg"
onClick={() => onSelect('weed')}
className="h-14 px-6 rounded-full shadow-xl bg-gradient-to-r from-green-600 to-green-500 hover:from-green-500 hover:to-green-400 text-white border-none font-bold uppercase tracking-widest flex items-center gap-3 transition-all hover:scale-105 active:scale-95"
>
<Leaf className="h-5 w-5" />
<span>Marijuana</span>
</Button>
</div>
{/* Close Button Trigger Area (Visual Feedback) */}
<button
onClick={onClose}
className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40 hover:opacity-100 transition-opacity bg-white/5 px-4 py-1 rounded-full border border-white/10"
>
Close Menu
</button>
</div>
</div>
);
}