Files
med-plan-assistant/src/App.tsx

274 lines
9.6 KiB
TypeScript

/**
* Medication Plan Assistant - Main Application
*
* A pharmacokinetic simulation tool for lisdexamfetamine (Elvanse/Vyvanse)
* medication planning. Helps users visualize drug concentration profiles,
* manage deviations, and get dose correction suggestions.
*
* @author Andreas Weyer
* @license MIT
*/
import React from 'react';
import { GitBranch, Pin, PinOff } from 'lucide-react';
// Components
import DaySchedule from './components/day-schedule';
import SimulationChart from './components/simulation-chart';
import Settings from './components/settings';
import LanguageSelector from './components/language-selector';
import DisclaimerModal from './components/disclaimer-modal';
import { Button } from './components/ui/button';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './components/ui/tooltip';
import { IconButtonWithTooltip } from './components/ui/icon-button-with-tooltip';
import { PROJECT_REPOSITORY_URL, APP_VERSION } from './constants/defaults';
// Custom Hooks
import { useAppState } from './hooks/useAppState';
import { useSimulation } from './hooks/useSimulation';
import { useLanguage } from './hooks/useLanguage';
// --- Main Component ---
const MedPlanAssistant = () => {
const { currentLanguage, t, changeLanguage } = useLanguage();
// Disclaimer modal state
const [showDisclaimer, setShowDisclaimer] = React.useState(false);
React.useEffect(() => {
const hasAccepted = localStorage.getItem('medPlanDisclaimerAccepted_v1');
if (!hasAccepted) {
setShowDisclaimer(true);
}
}, []);
const handleAcceptDisclaimer = () => {
localStorage.setItem('medPlanDisclaimerAccepted_v1', 'true');
setShowDisclaimer(false);
};
const handleOpenDisclaimer = () => {
setShowDisclaimer(true);
};
// Use shorter button labels on narrow screens to keep the pin control visible
const [useCompactButtons, setUseCompactButtons] = React.useState(false);
React.useEffect(() => {
const updateCompact = () => {
setUseCompactButtons(window.innerWidth < 520); // tweakable threshold
};
updateCompact();
window.addEventListener('resize', updateCompact);
return () => window.removeEventListener('resize', updateCompact);
}, []);
const {
appState,
updateState,
updateNestedState,
updateUiSetting,
handleReset,
addDay,
removeDay,
addDoseToDay,
removeDoseFromDay,
updateDoseInDay,
sortDosesInDay
} = useAppState();
const {
pkParams,
days,
therapeuticRange,
doseIncrement,
uiSettings
} = appState;
const {
showDayTimeOnXAxis,
chartView,
yAxisMin,
yAxisMax,
showTemplateDay,
simulationDays,
displayedDays,
showDayReferenceLines
} = uiSettings;
const {
combinedProfile,
templateProfile
} = useSimulation(appState);
return (
<TooltipProvider>
<div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8">
{/* Disclaimer Modal */}
<DisclaimerModal
isOpen={showDisclaimer}
onAccept={handleAcceptDisclaimer}
currentLanguage={currentLanguage}
onLanguageChange={changeLanguage}
t={t}
/>
<div className="max-w-7xl mx-auto">
<header className="mb-8">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">{t('appTitle')}</h1>
</div>
<LanguageSelector currentLanguage={currentLanguage} onLanguageChange={changeLanguage} t={t} />
</div>
<p className="text-muted-foreground mt-1">{t('appSubtitle')}</p>
</header>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Both Columns - Chart */}
<div className={`xl:col-span-2 bg-card p-6 rounded-lg border min-h-[600px] flex flex-col ${uiSettings.stickyChart ? 'sticky top-2 z-30 shadow-lg' : ''}`}
style={uiSettings.stickyChart ? { borderColor: 'hsl(var(--primary))' } : {}}>
<div className="flex flex-wrap items-center gap-3 justify-between mb-4">
<div className="flex flex-wrap justify-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => updateUiSetting('chartView', 'damph')}
variant={chartView === 'damph' ? 'default' : 'secondary'}
>
{t(useCompactButtons ? 'dAmphetamineShort' : 'dAmphetamine')}
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-xs">{t('chartViewDamphTooltip')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => updateUiSetting('chartView', 'ldx')}
variant={chartView === 'ldx' ? 'default' : 'secondary'}
>
{t(useCompactButtons ? 'lisdexamfetamineShort' : 'lisdexamfetamine')}
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-xs">{t('chartViewLdxTooltip')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => updateUiSetting('chartView', 'both')}
variant={chartView === 'both' ? 'default' : 'secondary'}
>
{t('both')}
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-xs">{t('chartViewBothTooltip')}</p>
</TooltipContent>
</Tooltip>
</div>
<IconButtonWithTooltip
onClick={() => updateUiSetting('stickyChart', !uiSettings.stickyChart)}
icon={uiSettings.stickyChart ? <Pin size={16} /> : <PinOff size={16} />}
tooltip={uiSettings.stickyChart ? t('unpinChart') : t('pinChart')}
variant={uiSettings.stickyChart ? 'default' : 'outline'}
size="sm"
className="shrink-0"
/>
</div>
<SimulationChart
combinedProfile={combinedProfile}
templateProfile={showTemplateDay ? templateProfile : null}
chartView={chartView}
showDayTimeOnXAxis={showDayTimeOnXAxis}
showDayReferenceLines={showDayReferenceLines}
showTherapeuticRange={uiSettings.showTherapeuticRange ?? true}
therapeuticRange={therapeuticRange}
simulationDays={simulationDays}
displayedDays={displayedDays}
yAxisMin={yAxisMin}
yAxisMax={yAxisMax}
days={days}
t={t}
/>
</div>
{/* Left Column - Controls */}
<div className="xl:col-span-1 space-y-6">
<DaySchedule
days={days}
doseIncrement={doseIncrement}
onAddDay={addDay}
onRemoveDay={removeDay}
onAddDose={addDoseToDay}
onRemoveDose={removeDoseFromDay}
onUpdateDose={updateDoseInDay}
onSortDoses={sortDosesInDay}
t={t}
/>
</div>
{/* Right Column - Settings */}
<div className="xl:col-span-1 space-y-6">
<Settings
pkParams={pkParams}
therapeuticRange={therapeuticRange}
uiSettings={uiSettings}
days={days}
doseIncrement={doseIncrement}
onUpdatePkParams={(key: any, value: any) => updateNestedState('pkParams', key, value)}
onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)}
onUpdateUiSetting={updateUiSetting}
onReset={handleReset}
onImportDays={(importedDays: any) => updateState('days', importedDays)}
t={t}
/>
</div>
</div>
<footer className="mt-8 p-4 bg-muted rounded-lg text-sm text-muted-foreground border">
<div className="space-y-3">
<div>
<h3 className="font-semibold mb-2 text-foreground">{t('importantNote')}</h3>
<p>{t('disclaimer')}</p>
</div>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={handleOpenDisclaimer}
>
{t('disclaimerModalFooterLink')}
</Button>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground" title={`Version: ${APP_VERSION}${APP_VERSION.endsWith('-dirty') ? ' (uncommitted changes)' : ''}`}>
v{APP_VERSION}
</span>
<a
href={PROJECT_REPOSITORY_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-8 h-8 rounded-md hover:bg-accent text-foreground hover:text-accent-foreground transition-colors"
title={t('footerProjectRepo')}
>
<GitBranch size={18} />
</a>
</div>
</div>
</div>
</footer>
</div>
</div>
</TooltipProvider>
);
};
export default MedPlanAssistant;