feat: add URL-driven workspace state and streamed Codex research
This commit is contained in:
@@ -10,176 +10,176 @@ import {
|
||||
TriangleAlert,
|
||||
Wifi,
|
||||
Wind,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Dialog } from "#/components/dialog";
|
||||
import { useUrlState } from "#/features/navigation/use-url-state";
|
||||
|
||||
type Tone = 'green' | 'blue' | 'purple' | 'yellow'
|
||||
type Tone = "green" | "blue" | "purple" | "yellow";
|
||||
type Metric = {
|
||||
name: string
|
||||
value: string
|
||||
unit: string
|
||||
tone: Tone
|
||||
status?: string
|
||||
min: string
|
||||
max: string
|
||||
}
|
||||
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: "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: "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: "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: "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: "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: "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: "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: "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',
|
||||
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: '',
|
||||
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',
|
||||
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³',
|
||||
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: '',
|
||||
name: "Comfort index",
|
||||
value: "80",
|
||||
unit: "",
|
||||
status: "Good",
|
||||
change: "+2",
|
||||
suffix: "",
|
||||
progress: 0.8,
|
||||
},
|
||||
]
|
||||
];
|
||||
const series = [
|
||||
{
|
||||
name: 'PM2.5',
|
||||
unit: 'µg/m³',
|
||||
tone: 'green',
|
||||
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,
|
||||
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',
|
||||
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,
|
||||
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',
|
||||
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,
|
||||
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',
|
||||
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,
|
||||
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
|
||||
] as const;
|
||||
|
||||
function points(values: readonly number[], width = 640, height = 190) {
|
||||
return values
|
||||
@@ -187,14 +187,14 @@ function points(values: readonly number[], width = 640, height = 190) {
|
||||
(value, index) =>
|
||||
`${(index * width) / (values.length - 1)},${height - (value * height) / 100}`,
|
||||
)
|
||||
.join(' ')
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function Sparkline({ index, tone = 'green' }: { index: number; tone?: Tone }) {
|
||||
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)
|
||||
: (series[index % series.length]?.values ?? series[0].values);
|
||||
return (
|
||||
<svg
|
||||
className="vital-sparkline"
|
||||
@@ -210,7 +210,7 @@ function Sparkline({ index, tone = 'green' }: { index: number; tone?: Tone }) {
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Gauge({ value }: { value: number }) {
|
||||
@@ -233,37 +233,32 @@ function Gauge({ value }: { value: number }) {
|
||||
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 [search, setSearch] = useUrlState();
|
||||
const range = search.environmentRange ?? "48H";
|
||||
const metric = series.some((item) => item.name === search.environmentMetric)
|
||||
? search.environmentMetric
|
||||
: "Overview";
|
||||
const setRange = (value: "24H" | "48H" | "7D") =>
|
||||
setSearch({ environmentRange: value === "48H" ? undefined : value });
|
||||
const setMetric = (value: string) =>
|
||||
setSearch({ environmentMetric: value === "Overview" ? undefined : value });
|
||||
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',
|
||||
]
|
||||
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"
|
||||
>
|
||||
<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) => (
|
||||
{(["24H", "48H", "7D"] as const).map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
@@ -275,11 +270,8 @@ function Trends() {
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
<fieldset
|
||||
className="vital-chart-tabs vital-segmented"
|
||||
aria-label="Trend metric"
|
||||
>
|
||||
{['Overview', ...series.map((item) => item.name)].map((item) => (
|
||||
<fieldset className="vital-chart-tabs vital-segmented" aria-label="Trend metric">
|
||||
{["Overview", ...series.map((item) => item.name)].map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
@@ -320,7 +312,7 @@ function Trends() {
|
||||
fill="var(--vital-muted)"
|
||||
fontSize="12"
|
||||
>
|
||||
{['1,500', '1,000', '500', '0'][index]}
|
||||
{["1,500", "1,000", "500", "0"][index]}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
@@ -333,11 +325,11 @@ function Trends() {
|
||||
<g transform="translate(56,26)">
|
||||
{visible.map((item) => {
|
||||
const values =
|
||||
range === '24H'
|
||||
range === "24H"
|
||||
? item.values.slice(15)
|
||||
: range === '7D'
|
||||
: range === "7D"
|
||||
? [...item.values.slice(10), ...item.values.slice(0, 10)]
|
||||
: item.values
|
||||
: item.values;
|
||||
return (
|
||||
<polyline
|
||||
key={item.name}
|
||||
@@ -347,7 +339,7 @@ function Trends() {
|
||||
strokeWidth="2.3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
{labels.map((label, index) => (
|
||||
@@ -364,22 +356,18 @@ function Trends() {
|
||||
))}
|
||||
</svg>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AirMonitor() {
|
||||
const [synced, setSynced] = useState(false)
|
||||
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)}
|
||||
>
|
||||
<button className="vital-outline-button" type="button" onClick={() => setSynced(true)}>
|
||||
<RefreshCw size={15} />
|
||||
{synced ? 'Synced' : 'Sync now'}
|
||||
{synced ? "Synced" : "Sync now"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="vital-device">
|
||||
@@ -390,11 +378,9 @@ function AirMonitor() {
|
||||
<span data-tone="green">● Connected</span>
|
||||
</div>
|
||||
<p>Air monitor</p>
|
||||
<p role="status">
|
||||
{synced ? 'Sample readings refreshed' : 'Updated just now'}
|
||||
</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) => (
|
||||
{["CO₂", "PM2.5", "Temp", "Humidity", "VOC", "NOx"].map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -402,11 +388,11 @@ function AirMonitor() {
|
||||
</div>
|
||||
<p className="vital-monitor-footnote">
|
||||
{synced
|
||||
? '71 sample readings synced. No device connected.'
|
||||
: '71 sample readings · simulated device'}
|
||||
? "71 sample readings synced. No device connected."
|
||||
: "71 sample readings · simulated device"}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Insights() {
|
||||
@@ -419,11 +405,7 @@ function Insights() {
|
||||
</div>
|
||||
<div className="vital-insight">
|
||||
<TriangleAlert data-tone="yellow" size={20} />
|
||||
<div
|
||||
className="vital-obscured"
|
||||
role="img"
|
||||
aria-label="Text obscured in reference image"
|
||||
>
|
||||
<div className="vital-obscured" role="img" aria-label="Text obscured in reference image">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
@@ -434,20 +416,14 @@ function Insights() {
|
||||
<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>
|
||||
<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>
|
||||
<p>The room’s current sample reading is 64%. Select Humidity to explore the chart.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vital-insight">
|
||||
@@ -455,8 +431,8 @@ function Insights() {
|
||||
<div>
|
||||
<h3>Room temperature</h3>
|
||||
<p>
|
||||
The current sample temperature is 64.0°F. Recent readings are
|
||||
shown alongside the other room metrics.
|
||||
The current sample temperature is 64.0°F. Recent readings are shown alongside the
|
||||
other room metrics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,11 +441,7 @@ function Insights() {
|
||||
<h2>Recommended actions</h2>
|
||||
<div className="vital-insight">
|
||||
<Grid2X2 size={20} />
|
||||
<div
|
||||
className="vital-obscured"
|
||||
role="img"
|
||||
aria-label="Text obscured in reference image"
|
||||
>
|
||||
<div className="vital-obscured" role="img" aria-label="Text obscured in reference image">
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
@@ -483,13 +455,13 @@ function Insights() {
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ClimateControl() {
|
||||
const [temperature, setTemperature] = useState(68)
|
||||
const [mode, setMode] = useState('Auto')
|
||||
const [purifier, setPurifier] = useState(true)
|
||||
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>
|
||||
@@ -506,7 +478,7 @@ function ClimateControl() {
|
||||
onChange={(event) => setTemperature(Number(event.target.value))}
|
||||
/>
|
||||
<fieldset className="vital-segmented" aria-label="Climate mode">
|
||||
{['Auto', 'Cool', 'Heat', 'Off'].map((item) => (
|
||||
{["Auto", "Cool", "Heat", "Off"].map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
@@ -524,37 +496,30 @@ function ClimateControl() {
|
||||
onClick={() => setPurifier(!purifier)}
|
||||
>
|
||||
<Wind size={18} />
|
||||
Air purifier · {purifier ? 'On' : 'Off'}
|
||||
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)
|
||||
const [search, setSearch] = useUrlState();
|
||||
const tab = search.environmentTab ?? "Overview";
|
||||
const detail = [...scores, ...metrics].find((item) => item.name === search.environmentDetail);
|
||||
const setTab = (value: "Overview" | "Climate control") =>
|
||||
setSearch({ environmentTab: value === "Overview" ? undefined : value });
|
||||
const setDetail = (value: { name: string } | null) =>
|
||||
setSearch({ environmentDetail: value?.name });
|
||||
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)}
|
||||
>
|
||||
<fieldset className="vital-segmented" aria-label="Environment view">
|
||||
{(["Overview", "Climate control"] as const).map((item) => (
|
||||
<button type="button" key={item} aria-pressed={tab === item} onClick={() => setTab(item)}>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
{tab === 'Climate control' ? (
|
||||
{tab === "Climate control" ? (
|
||||
<ClimateControl />
|
||||
) : (
|
||||
<div className="vital-env-layout">
|
||||
@@ -580,9 +545,7 @@ export function EnvironmentPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="vital-delta">
|
||||
<span data-tone={index === 3 ? 'green' : 'yellow'}>
|
||||
{score.change}
|
||||
</span>{' '}
|
||||
<span data-tone={index === 3 ? "green" : "yellow"}>{score.change}</span>{" "}
|
||||
{score.suffix} vs yesterday
|
||||
</p>
|
||||
<Sparkline index={index} />
|
||||
@@ -598,7 +561,7 @@ export function EnvironmentPanel() {
|
||||
? Droplets
|
||||
: index === 2
|
||||
? Cloud
|
||||
: Grid2X2
|
||||
: Grid2X2;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -613,11 +576,7 @@ export function EnvironmentPanel() {
|
||||
<strong>{metric.value}</strong>
|
||||
<small>{metric.unit}</small>
|
||||
{metric.status && (
|
||||
<em
|
||||
data-tone={
|
||||
metric.status === 'Watch' ? 'yellow' : 'green'
|
||||
}
|
||||
>
|
||||
<em data-tone={metric.status === "Watch" ? "yellow" : "green"}>
|
||||
{metric.status}
|
||||
</em>
|
||||
)}
|
||||
@@ -629,7 +588,7 @@ export function EnvironmentPanel() {
|
||||
<small>{metric.max}</small>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
<div className="vital-metric vital-card vital-room">
|
||||
<Wifi size={17} data-tone="green" />
|
||||
@@ -656,18 +615,14 @@ export function EnvironmentPanel() {
|
||||
{detail.value} <small>{detail.unit}</small>
|
||||
</p>
|
||||
<p>
|
||||
This is a sample reading for the dashboard preview. No health or
|
||||
environmental device is connected.
|
||||
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)}
|
||||
>
|
||||
<button className="vital-outline-button" type="button" onClick={() => setDetail(null)}>
|
||||
Back to overview
|
||||
</button>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+101
-114
@@ -1,4 +1,4 @@
|
||||
import { useRouteContext } from '@tanstack/react-router'
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
@@ -20,52 +20,69 @@ import {
|
||||
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'
|
||||
} 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 { useUrlState } from "#/features/navigation/use-url-state";
|
||||
import { EnvironmentPanel } from "./environment-panel";
|
||||
|
||||
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
|
||||
{ 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 },
|
||||
]
|
||||
{ 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 { auth } = useRouteContext({ from: '__root__' })
|
||||
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)
|
||||
const { auth } = useRouteContext({ from: "__root__" });
|
||||
const [search, setSearch] = useUrlState();
|
||||
const section = search.vitalSection ?? "Body";
|
||||
const tab = systems.some((item) => item.name === search.vitalTab) ? search.vitalTab : "Overview";
|
||||
const detail = [
|
||||
...systems.map((item) => item.name),
|
||||
...nav.map((item) => item.name),
|
||||
"Log with Claude",
|
||||
"Help",
|
||||
"Notifications",
|
||||
"Recent result",
|
||||
"Insights",
|
||||
].includes(search.vitalDetail ?? "")
|
||||
? search.vitalDetail
|
||||
: undefined;
|
||||
const history = search.vitalHistory ?? false;
|
||||
const setSection = (value: "Body" | "Environment") =>
|
||||
setSearch({ vitalSection: value === "Body" ? undefined : value });
|
||||
const setTab = (value: string) =>
|
||||
setSearch({ vitalTab: value === "Overview" ? undefined : value });
|
||||
const setDetail = (value: string | undefined) => setSearch({ vitalDetail: value });
|
||||
const setHistory = (value: boolean) => setSearch({ vitalHistory: value || undefined });
|
||||
const [mobileMenu, setMobileMenu] = useState(false);
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider className="vital-workspace">
|
||||
<WorkspaceNavigation />
|
||||
<div className="vital-app">
|
||||
<aside
|
||||
className={`vital-navigation ${mobileMenu ? 'is-open' : ''}`}
|
||||
className={`vital-navigation ${mobileMenu ? "is-open" : ""}`}
|
||||
aria-label="Health navigation"
|
||||
>
|
||||
<div className="vital-brand">
|
||||
@@ -87,12 +104,12 @@ export function VitalsPage() {
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
className={section === item.name ? 'is-active' : ''}
|
||||
className={section === item.name ? "is-active" : ""}
|
||||
onClick={() => {
|
||||
if (item.name === 'Body' || item.name === 'Environment') {
|
||||
setSection(item.name)
|
||||
setMobileMenu(false)
|
||||
} else setDetail(item.name)
|
||||
if (item.name === "Body" || item.name === "Environment") {
|
||||
setSection(item.name);
|
||||
setMobileMenu(false);
|
||||
} else setDetail(item.name);
|
||||
}}
|
||||
>
|
||||
<item.icon />
|
||||
@@ -101,14 +118,11 @@ export function VitalsPage() {
|
||||
))}
|
||||
</nav>
|
||||
<div className="vital-navigation-bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetail('Log with Claude')}
|
||||
>
|
||||
<button type="button" onClick={() => setDetail("Log with Claude")}>
|
||||
<Plus />
|
||||
Log with Claude
|
||||
</button>
|
||||
<button type="button" onClick={() => setDetail('Help')}>
|
||||
<button type="button" onClick={() => setDetail("Help")}>
|
||||
<CircleHelp />
|
||||
Help
|
||||
</button>
|
||||
@@ -134,44 +148,42 @@ export function VitalsPage() {
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open notifications"
|
||||
onClick={() => setDetail('Notifications')}
|
||||
onClick={() => setDetail("Notifications")}
|
||||
>
|
||||
<Bell />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vital-profile"
|
||||
onClick={() => setDetail('Profile')}
|
||||
onClick={() => setDetail("Profile")}
|
||||
>
|
||||
<span className="vital-avatar">
|
||||
{auth.owner?.name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="vital-avatar">{auth.owner?.name.slice(0, 1).toUpperCase()}</span>
|
||||
{auth.owner?.name}
|
||||
<ChevronDown />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="vital-scroll">
|
||||
{section === 'Environment' ? (
|
||||
{section === "Environment" ? (
|
||||
<EnvironmentPanel />
|
||||
) : (
|
||||
<div className="vital-body-page">
|
||||
<div className="vital-tabs">
|
||||
<div>
|
||||
<div className="vital-tabs" role="group" aria-label="Body systems">
|
||||
{[
|
||||
'Overview',
|
||||
'Cardiovascular',
|
||||
'Metabolic',
|
||||
'Liver',
|
||||
'Kidney',
|
||||
'Endocrine',
|
||||
'Immune',
|
||||
'Blood',
|
||||
'Nutrients',
|
||||
"Overview",
|
||||
"Cardiovascular",
|
||||
"Metabolic",
|
||||
"Liver",
|
||||
"Kidney",
|
||||
"Endocrine",
|
||||
"Immune",
|
||||
"Blood",
|
||||
"Nutrients",
|
||||
].map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
className={tab === name ? 'is-active' : ''}
|
||||
className={tab === name ? "is-active" : ""}
|
||||
onClick={() => setTab(name)}
|
||||
>
|
||||
{name}
|
||||
@@ -179,7 +191,7 @@ export function VitalsPage() {
|
||||
))}
|
||||
</div>
|
||||
<div className="vital-body-columns">
|
||||
<div className="vital-card vital-body-main">
|
||||
<div className="vital-card">
|
||||
<div className="vital-lab-row">
|
||||
<section className="vital-lab-score">
|
||||
<header>
|
||||
@@ -206,10 +218,7 @@ export function VitalsPage() {
|
||||
<h3>
|
||||
Optimal <span>▾ 1</span>
|
||||
</h3>
|
||||
<p>
|
||||
7 of 8 systems optimal · 21 of 161 biomarkers
|
||||
out of range
|
||||
</p>
|
||||
<p>7 of 8 systems optimal · 21 of 161 biomarkers out of range</p>
|
||||
<div className="vital-lab-change">
|
||||
<div>
|
||||
<span>Change ⓘ</span>
|
||||
@@ -218,11 +227,7 @@ export function VitalsPage() {
|
||||
</strong>
|
||||
<p>Since last draw</p>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 190 65"
|
||||
aria-label="Lab score trend"
|
||||
role="img"
|
||||
>
|
||||
<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"
|
||||
@@ -240,7 +245,7 @@ export function VitalsPage() {
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => setDetail('Recent result')}
|
||||
onClick={() => setDetail("Recent result")}
|
||||
>
|
||||
<span className="vital-warning">△</span>
|
||||
<span
|
||||
@@ -258,18 +263,18 @@ export function VitalsPage() {
|
||||
</div>
|
||||
<section className="vital-systems">
|
||||
<header>
|
||||
<h2>{tab === 'Overview' ? 'Body systems' : tab}</h2>
|
||||
<div className="vital-tabs">
|
||||
<h2>{tab === "Overview" ? "Body systems" : tab}</h2>
|
||||
<div className="vital-tabs" role="group" aria-label="Body systems">
|
||||
<button
|
||||
type="button"
|
||||
className={!history ? 'is-active' : ''}
|
||||
className={!history ? "is-active" : ""}
|
||||
onClick={() => setHistory(false)}
|
||||
>
|
||||
Systems
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={history ? 'is-active' : ''}
|
||||
className={history ? "is-active" : ""}
|
||||
onClick={() => setHistory(true)}
|
||||
>
|
||||
Lab history
|
||||
@@ -280,9 +285,9 @@ export function VitalsPage() {
|
||||
<div className="vital-history">
|
||||
<h3>Lab history</h3>
|
||||
{[
|
||||
['Jul 28', '92'],
|
||||
['May 16', '93'],
|
||||
['Mar 04', '89'],
|
||||
["Jul 28", "92"],
|
||||
["May 16", "93"],
|
||||
["Mar 04", "89"],
|
||||
].map(([date, score]) => (
|
||||
<div key={date}>
|
||||
<span>{date}</span>
|
||||
@@ -298,10 +303,7 @@ export function VitalsPage() {
|
||||
<div className="vital-system-list">
|
||||
{systems
|
||||
.slice(0, 4)
|
||||
.filter(
|
||||
(item) =>
|
||||
tab === 'Overview' || tab === item.name,
|
||||
)
|
||||
.filter((item) => tab === "Overview" || tab === item.name)
|
||||
.map((item) => (
|
||||
<SystemCard
|
||||
key={item.name}
|
||||
@@ -320,10 +322,7 @@ export function VitalsPage() {
|
||||
<div className="vital-system-list">
|
||||
{systems
|
||||
.slice(4)
|
||||
.filter(
|
||||
(item) =>
|
||||
tab === 'Overview' || tab === item.name,
|
||||
)
|
||||
.filter((item) => tab === "Overview" || tab === item.name)
|
||||
.map((item) => (
|
||||
<SystemCard
|
||||
key={item.name}
|
||||
@@ -369,7 +368,7 @@ export function VitalsPage() {
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div className="vital-insight-placeholder" key={index}>
|
||||
<b
|
||||
className={`vital-dot ${index === 2 ? 'vital-yellow' : 'vital-red'}`}
|
||||
className={`vital-dot ${index === 2 ? "vital-yellow" : "vital-red"}`}
|
||||
/>
|
||||
<div
|
||||
className="vital-obscured"
|
||||
@@ -385,7 +384,7 @@ export function VitalsPage() {
|
||||
<button
|
||||
className="vital-text-button"
|
||||
type="button"
|
||||
onClick={() => setDetail('Insights')}
|
||||
onClick={() => setDetail("Insights")}
|
||||
>
|
||||
Show 6 more
|
||||
</button>
|
||||
@@ -398,21 +397,15 @@ export function VitalsPage() {
|
||||
</div>
|
||||
</div>
|
||||
{detail && (
|
||||
<Dialog
|
||||
title={detail}
|
||||
className="sm:max-w-xl"
|
||||
onClose={() => setDetail(undefined)}
|
||||
>
|
||||
<Dialog title={detail} className="sm:max-w-xl" onClose={() => setDetail(undefined)}>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
This preview uses sample data from the reference image. It is not
|
||||
connected to health measurements, diagnoses, or device controls.
|
||||
This preview uses sample data from the reference image. It is not connected to health
|
||||
measurements, diagnoses, or device controls.
|
||||
</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>
|
||||
<span className="ml-2 text-sm text-muted-foreground">score</span>
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setDetail(undefined)}>
|
||||
@@ -422,15 +415,9 @@ export function VitalsPage() {
|
||||
)}
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
function SystemCard({
|
||||
item,
|
||||
onSelect,
|
||||
}: {
|
||||
item: (typeof systems)[number]
|
||||
onSelect: () => void
|
||||
}) {
|
||||
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}`}>
|
||||
@@ -442,10 +429,10 @@ function SystemCard({
|
||||
</span>
|
||||
<span className="vital-system-score">
|
||||
{item.score}
|
||||
<small className={item.score < 70 ? 'vital-yellow' : 'vital-green'}>
|
||||
● {item.score < 70 ? 'Watch' : 'Optimal'}
|
||||
<small className={item.score < 70 ? "vital-yellow" : "vital-green"}>
|
||||
● {item.score < 70 ? "Watch" : "Optimal"}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user