feat: add daily home and vitals dashboard prototypes
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
Droplets,
|
||||
Grid2X2,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Thermometer,
|
||||
TriangleAlert,
|
||||
Wifi,
|
||||
Wind,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
|
||||
type Tone = 'green' | 'blue' | 'purple' | 'yellow'
|
||||
type Metric = {
|
||||
name: string
|
||||
value: string
|
||||
unit: string
|
||||
tone: Tone
|
||||
status?: string
|
||||
min: string
|
||||
max: string
|
||||
}
|
||||
const metrics: Metric[] = [
|
||||
{
|
||||
name: 'Room temperature',
|
||||
value: '64.0',
|
||||
unit: '°F',
|
||||
tone: 'purple',
|
||||
status: 'Watch',
|
||||
min: '58.1',
|
||||
max: '73.6',
|
||||
},
|
||||
{
|
||||
name: 'Humidity',
|
||||
value: '64',
|
||||
unit: '%',
|
||||
tone: 'yellow',
|
||||
status: 'Watch',
|
||||
min: '47',
|
||||
max: '71',
|
||||
},
|
||||
{
|
||||
name: 'CO₂',
|
||||
value: '676',
|
||||
unit: 'ppm',
|
||||
tone: 'blue',
|
||||
status: 'Good',
|
||||
min: '437',
|
||||
max: '1,639',
|
||||
},
|
||||
{
|
||||
name: 'PM1.0',
|
||||
value: '0.0',
|
||||
unit: 'µg/m³',
|
||||
tone: 'purple',
|
||||
min: '0.0',
|
||||
max: '4.0',
|
||||
},
|
||||
{
|
||||
name: 'PM2.5',
|
||||
value: '0.8',
|
||||
unit: 'µg/m³',
|
||||
tone: 'green',
|
||||
status: 'Good',
|
||||
min: '0.0',
|
||||
max: '3.2',
|
||||
},
|
||||
{
|
||||
name: 'PM10',
|
||||
value: '1.0',
|
||||
unit: 'µg/m³',
|
||||
tone: 'purple',
|
||||
min: '0.0',
|
||||
max: '5.9',
|
||||
},
|
||||
{
|
||||
name: 'TVOC',
|
||||
value: '73',
|
||||
unit: 'ppb',
|
||||
tone: 'green',
|
||||
status: 'Good',
|
||||
min: '30',
|
||||
max: '1,252',
|
||||
},
|
||||
{
|
||||
name: 'NOx index',
|
||||
value: '1',
|
||||
unit: '',
|
||||
tone: 'green',
|
||||
status: 'Good',
|
||||
min: '1',
|
||||
max: '1',
|
||||
},
|
||||
{
|
||||
name: 'Particle count',
|
||||
value: '217',
|
||||
unit: 'particles/dL',
|
||||
tone: 'purple',
|
||||
min: '8',
|
||||
max: '817',
|
||||
},
|
||||
]
|
||||
const scores = [
|
||||
{
|
||||
name: 'Air quality score',
|
||||
value: '93',
|
||||
unit: '',
|
||||
status: 'Optimal',
|
||||
change: '−3',
|
||||
suffix: '',
|
||||
progress: 0.93,
|
||||
},
|
||||
{
|
||||
name: 'CO₂',
|
||||
value: '676',
|
||||
unit: 'ppm',
|
||||
status: 'Good',
|
||||
change: '+42',
|
||||
suffix: 'ppm',
|
||||
progress: 0.91,
|
||||
},
|
||||
{
|
||||
name: 'PM2.5',
|
||||
value: '0.8',
|
||||
unit: 'µg/m³',
|
||||
status: 'Good',
|
||||
change: '+0.4',
|
||||
suffix: 'µg/m³',
|
||||
progress: 0.96,
|
||||
},
|
||||
{
|
||||
name: 'Comfort index',
|
||||
value: '80',
|
||||
unit: '',
|
||||
status: 'Good',
|
||||
change: '+2',
|
||||
suffix: '',
|
||||
progress: 0.8,
|
||||
},
|
||||
]
|
||||
const series = [
|
||||
{
|
||||
name: 'PM2.5',
|
||||
unit: 'µg/m³',
|
||||
tone: 'green',
|
||||
values: [
|
||||
2, 1, 0, 0, 0, 1, 0, 6, 1, 1, 0, 0, 5, 21, 14, 9, 0, 0, 1, 2, 0, 2, 5, 62,
|
||||
40, 21, 11, 4, 20, 54,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'CO₂',
|
||||
unit: 'ppm',
|
||||
tone: 'blue',
|
||||
values: [
|
||||
38, 34, 38, 41, 39, 29, 67, 56, 50, 53, 56, 53, 54, 34, 34, 32, 40, 42,
|
||||
29, 59, 95, 58, 50, 48, 49, 50, 50, 56, 39, 48,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Room temperature',
|
||||
unit: '°F',
|
||||
tone: 'purple',
|
||||
values: [
|
||||
55, 60, 66, 69, 69, 64, 62, 43, 36, 35, 33, 27, 29, 43, 57, 65, 71, 75,
|
||||
72, 69, 66, 45, 39, 34, 30, 27, 29, 33, 36, 39,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Humidity',
|
||||
unit: '%',
|
||||
tone: 'yellow',
|
||||
values: [
|
||||
4, 4, 3, 3, 3, 3, 4, 4, 5, 5, 4, 4, 4, 5, 4, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
|
||||
3, 3, 3, 4, 4,
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
function points(values: readonly number[], width = 640, height = 190) {
|
||||
return values
|
||||
.map(
|
||||
(value, index) =>
|
||||
`${(index * width) / (values.length - 1)},${height - (value * height) / 100}`,
|
||||
)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function Sparkline({ index, tone = 'green' }: { index: number; tone?: Tone }) {
|
||||
const values =
|
||||
index === 7
|
||||
? Array<number>(30).fill(20)
|
||||
: (series[index % series.length]?.values ?? series[0].values)
|
||||
return (
|
||||
<svg
|
||||
className="vital-sparkline"
|
||||
viewBox="0 0 300 40"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline
|
||||
points={points(values, 300, 36)}
|
||||
fill="none"
|
||||
stroke={`var(--vital-${tone})`}
|
||||
strokeWidth="1.8"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Gauge({ value }: { value: number }) {
|
||||
return (
|
||||
<svg className="vital-gauge" viewBox="0 0 110 65" aria-hidden="true">
|
||||
<path
|
||||
d="M 5 58 A 50 50 0 0 1 105 58"
|
||||
fill="none"
|
||||
stroke="var(--vital-border)"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 5 58 A 50 50 0 0 1 105 58"
|
||||
fill="none"
|
||||
stroke="var(--vital-green)"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
pathLength="100"
|
||||
strokeDasharray={`${value * 100} 100`}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Trends() {
|
||||
const [range, setRange] = useState('48H')
|
||||
const [metric, setMetric] = useState('Overview')
|
||||
const visible = series.filter(
|
||||
(item) => metric === 'Overview' || item.name === metric,
|
||||
)
|
||||
const labels =
|
||||
range === '7D'
|
||||
? ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
: range === '24H'
|
||||
? ['12 AM', '4 AM', '8 AM', '12 PM', '4 PM', '8 PM']
|
||||
: [
|
||||
'Mon 7 PM',
|
||||
'Tue 3 AM',
|
||||
'Tue 11 AM',
|
||||
'Tue 7 PM',
|
||||
'Wed 3 AM',
|
||||
'Wed 11 AM',
|
||||
]
|
||||
return (
|
||||
<section
|
||||
className="vital-trends vital-card"
|
||||
aria-label="Environmental trends"
|
||||
>
|
||||
<div className="vital-card-header">
|
||||
<h2>Trends</h2>
|
||||
<fieldset className="vital-segmented" aria-label="Trend period">
|
||||
{['24H', '48H', '7D'].map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
aria-pressed={range === item}
|
||||
onClick={() => setRange(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
<fieldset
|
||||
className="vital-chart-tabs vital-segmented"
|
||||
aria-label="Trend metric"
|
||||
>
|
||||
{['Overview', ...series.map((item) => item.name)].map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
aria-pressed={metric === item}
|
||||
onClick={() => setMetric(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<div className="vital-chart-legend">
|
||||
{visible.map((item) => (
|
||||
<span key={item.name} data-tone={item.tone}>
|
||||
<i />
|
||||
{item.name} ({item.unit})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<svg
|
||||
className="vital-trend-chart"
|
||||
viewBox="0 0 740 260"
|
||||
role="img"
|
||||
aria-label={`${range} ${metric} simulated environmental readings`}
|
||||
>
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<g key={index}>
|
||||
<line
|
||||
x1="56"
|
||||
x2="696"
|
||||
y1={30 + index * 62}
|
||||
y2={30 + index * 62}
|
||||
stroke="var(--vital-border)"
|
||||
/>
|
||||
<text
|
||||
x="46"
|
||||
y={35 + index * 62}
|
||||
textAnchor="end"
|
||||
fill="var(--vital-muted)"
|
||||
fontSize="12"
|
||||
>
|
||||
{['1,500', '1,000', '500', '0'][index]}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<text x="6" y="14" fill="var(--vital-muted)" fontSize="12">
|
||||
ppm / %
|
||||
</text>
|
||||
<text x="712" y="14" fill="var(--vital-purple)" fontSize="12">
|
||||
°F
|
||||
</text>
|
||||
<g transform="translate(56,26)">
|
||||
{visible.map((item) => {
|
||||
const values =
|
||||
range === '24H'
|
||||
? item.values.slice(15)
|
||||
: range === '7D'
|
||||
? [...item.values.slice(10), ...item.values.slice(0, 10)]
|
||||
: item.values
|
||||
return (
|
||||
<polyline
|
||||
key={item.name}
|
||||
points={points(values)}
|
||||
fill="none"
|
||||
stroke={`var(--vital-${item.tone})`}
|
||||
strokeWidth="2.3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
{labels.map((label, index) => (
|
||||
<text
|
||||
key={label}
|
||||
x={75 + (index * 610) / (labels.length - 1)}
|
||||
y="242"
|
||||
fill="var(--vital-muted)"
|
||||
fontSize="12"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AirMonitor() {
|
||||
const [synced, setSynced] = useState(false)
|
||||
return (
|
||||
<section className="vital-monitor vital-card">
|
||||
<div className="vital-card-header">
|
||||
<h2>Air monitor</h2>
|
||||
<button
|
||||
className="vital-outline-button"
|
||||
type="button"
|
||||
onClick={() => setSynced(true)}
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
{synced ? 'Synced' : 'Sync now'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="vital-device">
|
||||
<Radio className="vital-device-icon" size={66} strokeWidth={1} />
|
||||
<div>
|
||||
<div className="vital-device-heading">
|
||||
<strong>Room</strong>
|
||||
<span data-tone="green">● Connected</span>
|
||||
</div>
|
||||
<p>Air monitor</p>
|
||||
<p role="status">
|
||||
{synced ? 'Sample readings refreshed' : 'Updated just now'}
|
||||
</p>
|
||||
<div className="vital-device-badges">
|
||||
{['CO₂', 'PM2.5', 'Temp', 'Humidity', 'VOC', 'NOx'].map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="vital-monitor-footnote">
|
||||
{synced
|
||||
? '71 sample readings synced. No device connected.'
|
||||
: '71 sample readings · simulated device'}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Insights() {
|
||||
return (
|
||||
<aside className="vital-env-insights">
|
||||
<section className="vital-insights vital-card">
|
||||
<div className="vital-card-header">
|
||||
<h2>Insights</h2>
|
||||
<span>today</span>
|
||||
</div>
|
||||
<div className="vital-insight">
|
||||
<TriangleAlert data-tone="yellow" size={20} />
|
||||
<div
|
||||
className="vital-obscured"
|
||||
role="img"
|
||||
aria-label="Text obscured in reference image"
|
||||
>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
</div>
|
||||
<div className="vital-insight">
|
||||
<Check data-tone="green" size={20} />
|
||||
<div>
|
||||
<h3>Particulates stayed low</h3>
|
||||
<p>
|
||||
The sample PM2.5 reading is 0.8 µg/m³. View its recent pattern in
|
||||
Trends.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vital-insight">
|
||||
<Check data-tone="green" size={20} />
|
||||
<div>
|
||||
<h3>Humidity through the day</h3>
|
||||
<p>
|
||||
The room’s current sample reading is 64%. Select Humidity to
|
||||
explore the chart.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vital-insight">
|
||||
<Check data-tone="green" size={20} />
|
||||
<div>
|
||||
<h3>Room temperature</h3>
|
||||
<p>
|
||||
The current sample temperature is 64.0°F. Recent readings are
|
||||
shown alongside the other room metrics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="vital-insights vital-card">
|
||||
<h2>Recommended actions</h2>
|
||||
<div className="vital-insight">
|
||||
<Grid2X2 size={20} />
|
||||
<div
|
||||
className="vital-obscured"
|
||||
role="img"
|
||||
aria-label="Text obscured in reference image"
|
||||
>
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
</div>
|
||||
<div className="vital-insight">
|
||||
<Droplets data-tone="yellow" size={20} />
|
||||
<div>
|
||||
<h3>Review room humidity</h3>
|
||||
<p>Explore the climate controls with simulated settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function ClimateControl() {
|
||||
const [temperature, setTemperature] = useState(68)
|
||||
const [mode, setMode] = useState('Auto')
|
||||
const [purifier, setPurifier] = useState(true)
|
||||
return (
|
||||
<section className="vital-climate vital-card">
|
||||
<h2>Room climate</h2>
|
||||
<p>Simulated controls. Changes stay on this screen.</p>
|
||||
<label htmlFor="vital-temperature">
|
||||
Target temperature <strong>{temperature}°F</strong>
|
||||
</label>
|
||||
<input
|
||||
id="vital-temperature"
|
||||
type="range"
|
||||
min="60"
|
||||
max="80"
|
||||
value={temperature}
|
||||
onChange={(event) => setTemperature(Number(event.target.value))}
|
||||
/>
|
||||
<fieldset className="vital-segmented" aria-label="Climate mode">
|
||||
{['Auto', 'Cool', 'Heat', 'Off'].map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
aria-pressed={item === mode}
|
||||
onClick={() => setMode(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<button
|
||||
className="vital-outline-button"
|
||||
type="button"
|
||||
aria-pressed={purifier}
|
||||
onClick={() => setPurifier(!purifier)}
|
||||
>
|
||||
<Wind size={18} />
|
||||
Air purifier · {purifier ? 'On' : 'Off'}
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnvironmentPanel() {
|
||||
const [tab, setTab] = useState('Overview')
|
||||
const [detail, setDetail] = useState<{
|
||||
name: string
|
||||
value: string
|
||||
unit: string
|
||||
} | null>(null)
|
||||
return (
|
||||
<>
|
||||
<fieldset
|
||||
className="vital-env-tabs vital-segmented"
|
||||
aria-label="Environment view"
|
||||
>
|
||||
{['Overview', 'Climate control'].map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
aria-pressed={tab === item}
|
||||
onClick={() => setTab(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
{tab === 'Climate control' ? (
|
||||
<ClimateControl />
|
||||
) : (
|
||||
<div className="vital-env-layout">
|
||||
<div className="vital-env-main">
|
||||
<div className="vital-env-scores">
|
||||
{scores.map((score, index) => (
|
||||
<button
|
||||
type="button"
|
||||
className="vital-env-score vital-card"
|
||||
key={score.name}
|
||||
onClick={() => setDetail(score)}
|
||||
>
|
||||
<span className="vital-score-heading">
|
||||
{score.name}
|
||||
<ChevronRight size={16} />
|
||||
</span>
|
||||
<div className="vital-score-reading">
|
||||
<Gauge value={score.progress} />
|
||||
<div>
|
||||
<strong>{score.value}</strong>
|
||||
<small>{score.unit}</small>
|
||||
<p data-tone="green">{score.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="vital-delta">
|
||||
<span data-tone={index === 3 ? 'green' : 'yellow'}>
|
||||
{score.change}
|
||||
</span>{' '}
|
||||
{score.suffix} vs yesterday
|
||||
</p>
|
||||
<Sparkline index={index} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="vital-env-metrics">
|
||||
{metrics.map((metric, index) => {
|
||||
const MetricIcon =
|
||||
index === 0
|
||||
? Thermometer
|
||||
: index === 1
|
||||
? Droplets
|
||||
: index === 2
|
||||
? Cloud
|
||||
: Grid2X2
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="vital-metric vital-card"
|
||||
key={metric.name}
|
||||
onClick={() => setDetail(metric)}
|
||||
>
|
||||
<MetricIcon size={17} data-tone={metric.tone} />
|
||||
<div className="vital-metric-content">
|
||||
<span>{metric.name}</span>
|
||||
<div className="vital-metric-reading">
|
||||
<strong>{metric.value}</strong>
|
||||
<small>{metric.unit}</small>
|
||||
{metric.status && (
|
||||
<em
|
||||
data-tone={
|
||||
metric.status === 'Watch' ? 'yellow' : 'green'
|
||||
}
|
||||
>
|
||||
{metric.status}
|
||||
</em>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vital-metric-spark">
|
||||
<small>{metric.min}</small>
|
||||
<Sparkline index={index} tone={metric.tone} />
|
||||
<small>{metric.max}</small>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="vital-metric vital-card vital-room">
|
||||
<Wifi size={17} data-tone="green" />
|
||||
<div>
|
||||
<span>Room</span>
|
||||
<strong>Connected</strong>
|
||||
<p>
|
||||
<span data-tone="green">●</span> Updated just now
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vital-env-bottom">
|
||||
<Trends />
|
||||
<AirMonitor />
|
||||
</div>
|
||||
</div>
|
||||
<Insights />
|
||||
</div>
|
||||
)}
|
||||
{detail && (
|
||||
<Dialog title={detail.name} onClose={() => setDetail(null)}>
|
||||
<p className="vital-detail-value">
|
||||
{detail.value} <small>{detail.unit}</small>
|
||||
</p>
|
||||
<p>
|
||||
This is a sample reading for the dashboard preview. No health or
|
||||
environmental device is connected.
|
||||
</p>
|
||||
<button
|
||||
className="vital-outline-button"
|
||||
type="button"
|
||||
onClick={() => setDetail(null)}
|
||||
>
|
||||
Back to overview
|
||||
</button>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
ChartNoAxesCombined,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
ClipboardList,
|
||||
Heart,
|
||||
House,
|
||||
Leaf,
|
||||
Lightbulb,
|
||||
Menu,
|
||||
Plus,
|
||||
Shield,
|
||||
Stethoscope,
|
||||
UserRound,
|
||||
Utensils,
|
||||
Waves,
|
||||
Wind,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import { SidebarProvider } from '#/components/ui/sidebar'
|
||||
import { TooltipProvider } from '#/components/ui/tooltip'
|
||||
import { WorkspaceNavigation } from '#/components/workspace-navigation'
|
||||
import { EnvironmentPanel } from './environment-panel'
|
||||
import './vitals.css'
|
||||
|
||||
const systems = [
|
||||
{ name: 'Cardiovascular', score: 68, count: 19, tone: 'red', icon: Heart },
|
||||
{ name: 'Liver', score: 95, count: 11, tone: 'yellow', icon: Shield },
|
||||
{ name: 'Immune', score: 95, count: 19, tone: 'purple', icon: Shield },
|
||||
{ name: 'Metabolic', score: 92, count: 6, tone: 'green', icon: Waves },
|
||||
{ name: 'Endocrine', score: 94, count: 25, tone: 'yellow', icon: Waves },
|
||||
{ name: 'Blood', score: 100, count: 17, tone: 'red', icon: Activity },
|
||||
{ name: 'Kidney', score: 98, count: 33, tone: 'purple', icon: Waves },
|
||||
{ name: 'Nutrients', score: 97, count: 17, tone: 'green', icon: Leaf },
|
||||
] as const
|
||||
const nav = [
|
||||
{ name: 'Home', icon: House },
|
||||
{ name: 'Body', icon: UserRound },
|
||||
{ name: 'Vitals', icon: Activity },
|
||||
{ name: 'Nutrition', icon: Utensils },
|
||||
{ name: 'Care', icon: Stethoscope },
|
||||
{ name: 'Environment', icon: Wind },
|
||||
{ name: 'Lights', icon: Lightbulb },
|
||||
{ name: 'Records', icon: ClipboardList },
|
||||
{ name: 'Analysis', icon: ChartNoAxesCombined },
|
||||
{ name: 'Profile', icon: UserRound },
|
||||
]
|
||||
export function VitalsPage() {
|
||||
const [section, setSection] = useState('Body')
|
||||
const [mobileMenu, setMobileMenu] = useState(false)
|
||||
const [detail, setDetail] = useState<string>()
|
||||
const [tab, setTab] = useState('Overview')
|
||||
const [history, setHistory] = useState(false)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider className="vital-workspace">
|
||||
<WorkspaceNavigation />
|
||||
<div className="vital-app">
|
||||
<aside
|
||||
className={`vital-navigation ${mobileMenu ? 'is-open' : ''}`}
|
||||
aria-label="Health navigation"
|
||||
>
|
||||
<div className="vital-brand">
|
||||
<Activity />
|
||||
<span>
|
||||
1F2T <span>Health</span>
|
||||
</span>
|
||||
<button
|
||||
className="vital-mobile-close"
|
||||
type="button"
|
||||
aria-label="メニューを閉じる"
|
||||
onClick={() => setMobileMenu(false)}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<nav>
|
||||
{nav.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
className={section === item.name ? 'is-active' : ''}
|
||||
onClick={() => {
|
||||
if (item.name === 'Body' || item.name === 'Environment') {
|
||||
setSection(item.name)
|
||||
setMobileMenu(false)
|
||||
} else setDetail(item.name)
|
||||
}}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="vital-navigation-bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetail('Log with Claude')}
|
||||
>
|
||||
<Plus />
|
||||
Log with Claude
|
||||
</button>
|
||||
<button type="button" onClick={() => setDetail('Help')}>
|
||||
<CircleHelp />
|
||||
Help
|
||||
</button>
|
||||
<p>Reference preview</p>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="vital-surface">
|
||||
<header className="vital-topbar">
|
||||
<button
|
||||
className="vital-mobile-toggle"
|
||||
type="button"
|
||||
aria-label="Healthメニューを開く"
|
||||
onClick={() => setMobileMenu(true)}
|
||||
>
|
||||
<Menu />
|
||||
</button>
|
||||
<h1>{section}</h1>
|
||||
<div className="vital-topbar-right">
|
||||
<span className="vital-pulse">
|
||||
<Heart />
|
||||
86 bpm
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="通知を開く"
|
||||
onClick={() => setDetail('Notifications')}
|
||||
>
|
||||
<Bell />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vital-profile"
|
||||
onClick={() => setDetail('Profile')}
|
||||
>
|
||||
<span className="vital-avatar">Y</span>Yuta
|
||||
<ChevronDown />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="vital-scroll">
|
||||
{section === 'Environment' ? (
|
||||
<EnvironmentPanel />
|
||||
) : (
|
||||
<div className="vital-body-page">
|
||||
<div className="vital-tabs">
|
||||
{[
|
||||
'Overview',
|
||||
'Cardiovascular',
|
||||
'Metabolic',
|
||||
'Liver',
|
||||
'Kidney',
|
||||
'Endocrine',
|
||||
'Immune',
|
||||
'Blood',
|
||||
'Nutrients',
|
||||
].map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
className={tab === name ? 'is-active' : ''}
|
||||
onClick={() => setTab(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="vital-body-columns">
|
||||
<div className="vital-card vital-body-main">
|
||||
<div className="vital-lab-row">
|
||||
<section className="vital-lab-score">
|
||||
<header>
|
||||
<h2>Lab score</h2>
|
||||
<span>Latest draw · Jul 28</span>
|
||||
</header>
|
||||
<div className="vital-lab-value">
|
||||
<div className="vital-ring">
|
||||
<svg viewBox="0 0 160 160" aria-hidden="true">
|
||||
<circle cx="80" cy="80" r="67" />
|
||||
<circle
|
||||
cx="80"
|
||||
cy="80"
|
||||
r="67"
|
||||
pathLength="100"
|
||||
strokeDasharray="92 100"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
92<small>Lab score</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="vital-lab-description">
|
||||
<h3>
|
||||
Optimal <span>▾ 1</span>
|
||||
</h3>
|
||||
<p>
|
||||
7 of 8 systems optimal · 21 of 161 biomarkers
|
||||
out of range
|
||||
</p>
|
||||
<div className="vital-lab-change">
|
||||
<div>
|
||||
<span>Change ⓘ</span>
|
||||
<strong>
|
||||
−1 <small>pt</small>
|
||||
</strong>
|
||||
<p>Since last draw</p>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 190 65"
|
||||
aria-label="Lab score trend"
|
||||
role="img"
|
||||
>
|
||||
<path
|
||||
d="M2 12 L24 12 Q34 8 39 29 T65 57 Q76 66 85 20 T105 19 L132 17 Q145 17 153 49 T174 48 L188 51"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="vital-recent">
|
||||
<h2>Recent results</h2>
|
||||
{[1, 2, 3].map((index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => setDetail('Recent result')}
|
||||
>
|
||||
<span className="vital-warning">△</span>
|
||||
<span
|
||||
className="vital-obscured"
|
||||
role="img"
|
||||
aria-label="Reference text obscured"
|
||||
>
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
<section className="vital-systems">
|
||||
<header>
|
||||
<h2>{tab === 'Overview' ? 'Body systems' : tab}</h2>
|
||||
<div className="vital-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={!history ? 'is-active' : ''}
|
||||
onClick={() => setHistory(false)}
|
||||
>
|
||||
Systems
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={history ? 'is-active' : ''}
|
||||
onClick={() => setHistory(true)}
|
||||
>
|
||||
Lab history
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{history ? (
|
||||
<div className="vital-history">
|
||||
<h3>Lab history</h3>
|
||||
{[
|
||||
['Jul 28', '92'],
|
||||
['May 16', '93'],
|
||||
['Mar 04', '89'],
|
||||
].map(([date, score]) => (
|
||||
<div key={date}>
|
||||
<span>{date}</span>
|
||||
<span>
|
||||
{score}
|
||||
<small> Lab score</small>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="vital-anatomy-grid">
|
||||
<div className="vital-system-list">
|
||||
{systems
|
||||
.slice(0, 4)
|
||||
.filter(
|
||||
(item) =>
|
||||
tab === 'Overview' || tab === item.name,
|
||||
)
|
||||
.map((item) => (
|
||||
<SystemCard
|
||||
key={item.name}
|
||||
item={item}
|
||||
onSelect={() => setDetail(item.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="vital-anatomy"
|
||||
role="img"
|
||||
aria-label="人体と各系統の位置を示す参照図"
|
||||
>
|
||||
<img src="/vitals/body-reference.jpg" alt="" />
|
||||
</div>
|
||||
<div className="vital-system-list">
|
||||
{systems
|
||||
.slice(4)
|
||||
.filter(
|
||||
(item) =>
|
||||
tab === 'Overview' || tab === item.name,
|
||||
)
|
||||
.map((item) => (
|
||||
<SystemCard
|
||||
key={item.name}
|
||||
item={item}
|
||||
onSelect={() => setDetail(item.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<aside className="vital-card vital-insights">
|
||||
<h2>Insights</h2>
|
||||
<div className="vital-insight-feature">
|
||||
<span className="vital-red">△</span>
|
||||
<div
|
||||
className="vital-obscured"
|
||||
role="img"
|
||||
aria-label="Reference text obscured"
|
||||
>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<button type="button" onClick={() => setHistory(true)}>
|
||||
View lab history
|
||||
</button>
|
||||
</div>
|
||||
<h3>Summary</h3>
|
||||
<div className="vital-summary">
|
||||
<span>
|
||||
<b className="vital-dot vital-red" />4 need attention
|
||||
</span>
|
||||
<span>
|
||||
<b className="vital-dot vital-yellow" />3 to watch
|
||||
</span>
|
||||
<span>
|
||||
<b className="vital-dot vital-green" />4 going well
|
||||
</span>
|
||||
</div>
|
||||
<h3>More insights</h3>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div className="vital-insight-placeholder" key={index}>
|
||||
<b
|
||||
className={`vital-dot ${index === 2 ? 'vital-yellow' : 'vital-red'}`}
|
||||
/>
|
||||
<div
|
||||
className="vital-obscured"
|
||||
role="img"
|
||||
aria-label="Reference text obscured"
|
||||
>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="vital-text-button"
|
||||
type="button"
|
||||
onClick={() => setDetail('Insights')}
|
||||
>
|
||||
Show 6 more
|
||||
</button>
|
||||
<p className="vital-updated">Reference data · Sep 25</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{detail && (
|
||||
<Dialog
|
||||
title={detail}
|
||||
className="sm:max-w-xl"
|
||||
onClose={() => setDetail(undefined)}
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
画像をもとにした表示サンプルです。実際の測定・診断・機器の操作には接続していません。
|
||||
</p>
|
||||
{systems.find((item) => item.name === detail) && (
|
||||
<p className="text-3xl">
|
||||
{systems.find((item) => item.name === detail)?.score}
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
score
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setDetail(undefined)}>
|
||||
閉じる
|
||||
</Button>
|
||||
</Dialog>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
function SystemCard({
|
||||
item,
|
||||
onSelect,
|
||||
}: {
|
||||
item: (typeof systems)[number]
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className="vital-system-card" onClick={onSelect}>
|
||||
<span className={`vital-system-icon vital-${item.tone}`}>
|
||||
<item.icon />
|
||||
</span>
|
||||
<span className="vital-system-label">
|
||||
{item.name}
|
||||
<small>{item.count} biomarkers</small>
|
||||
</span>
|
||||
<span className="vital-system-score">
|
||||
{item.score}
|
||||
<small className={item.score < 70 ? 'vital-yellow' : 'vital-green'}>
|
||||
● {item.score < 70 ? 'Watch' : 'Optimal'}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user