Rough prototype of visualizing my sleep data with Visx and Framer Motion.
This is a code snippet from a Next.js 14 app. Uses the Oura v2 API to get data from your account.
Rough prototype of visualizing my sleep data with Visx and Framer Motion.
This is a code snippet from a Next.js 14 app. Uses the Oura v2 API to get data from your account.
"use client"; | |
import useSWR from "swr"; | |
export const BASE_URL = process.env.NEXT_PUBLIC_VERCEL_URL | |
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` | |
: "http://localhost:3000"; | |
export const fetcher = (...args) => fetch(...args).then((res) => res.json()); | |
export function useOuraSleep() { | |
const { data, error, isLoading } = useSWR(`${BASE_URL}/api/oura/sleep`, fetcher, { | |
refreshInterval: 1000 * 60 * 60, // Refresh hourly | |
}); | |
return { | |
sleep: | |
data?.data?.map((day: any) => ({ | |
...day, | |
// Parse ISO strings to Date objects for easier handling in components | |
bedtime_start: new Date(day.bedtime_start), | |
bedtime_end: new Date(day.bedtime_end), | |
})) ?? [], | |
isLoading, | |
isError: error, | |
}; | |
} |
// api/oura/sleep/route.ts | |
import { NextResponse } from "next/server"; | |
const OURA_API_KEY = process.env.OURA_API_KEY; | |
export async function GET() { | |
const thirtyDaysAgo = new Date(); | |
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); | |
const startDate = thirtyDaysAgo.toISOString().split("T")[0]; | |
// Fetch both sleep data and sleep scores | |
const [sleepResponse, scoresResponse] = await Promise.all([ | |
fetch(`https://api.ouraring.com/v2/usercollection/sleep?start_date=${startDate}`, { | |
headers: { | |
Authorization: `Bearer ${OURA_API_KEY}`, | |
}, | |
}), | |
fetch(`https://api.ouraring.com/v2/usercollection/daily_sleep?start_date=${startDate}`, { | |
headers: { | |
Authorization: `Bearer ${OURA_API_KEY}`, | |
}, | |
}), | |
]); | |
const [sleepData, scoresData] = await Promise.all([sleepResponse.json(), scoresResponse.json()]); | |
// Create a map of dates to scores | |
const scoresByDate = new Map(scoresData.data.map((day: any) => [day.day, day.score])); | |
// Combine sleep data with scores | |
const data = sleepData.data.map((day: any) => ({ | |
date: day.day, | |
bedtime_start: day.bedtime_start, | |
bedtime_end: day.bedtime_end, | |
duration: day.total_sleep_duration, | |
time_in_bed: day.time_in_bed, | |
latency: day.latency, | |
efficiency: day.efficiency, | |
deep_sleep_duration: day.deep_sleep_duration, | |
rem_sleep_duration: day.rem_sleep_duration, | |
light_sleep_duration: day.light_sleep_duration, | |
average_heart_rate: day.average_heart_rate, | |
lowest_heart_rate: day.lowest_heart_rate, | |
average_hrv: day.average_hrv, | |
score: scoresByDate.get(day.day) ?? 0, // Get score from the daily_sleep endpoint | |
})); | |
return NextResponse.json({ data }); | |
} |
"use client"; | |
import React from "react"; | |
import { Group } from "@visx/group"; | |
import { scaleTime, scaleLinear } from "@visx/scale"; | |
import type { NumberValue } from "d3-scale"; | |
import { AxisLeft, AxisBottom } from "@visx/axis"; | |
import { Grid } from "@visx/grid"; | |
import { Tooltip, defaultStyles } from "@visx/tooltip"; | |
import { ParentSize } from "@visx/responsive"; | |
import { extent } from "d3-array"; | |
import { motion, AnimatePresence } from "framer-motion"; | |
import { tw } from "@/app/_utils/tailwind"; | |
import { Heart, Activity, Moon, Sun } from "lucide-react"; | |
interface SleepData { | |
bedtime_start: Date; | |
bedtime_end: Date; | |
duration: number; | |
efficiency: number; | |
score: number; | |
lowest_heart_rate: number; | |
average_hrv: number; | |
} | |
interface SleepChartProps { | |
data: SleepData[]; | |
width: number; | |
height: number; | |
selectedRange: DateRange; | |
onRangeChange: (range: DateRange) => void; | |
} | |
interface TooltipData { | |
date: Date; | |
efficiency: number; | |
bedtime_end: Date; | |
duration: number; | |
score: number; | |
lowest_heart_rate: number; | |
average_hrv: number; | |
} | |
type DateRange = "30D" | "14D" | "7D"; | |
function BaseChart({ data, width, height, selectedRange, onRangeChange }: SleepChartProps) { | |
const margin = { top: 8, right: 24, bottom: 32, left: 56 }; | |
const [tooltipData, setTooltipData] = React.useState<TooltipData | null>(null); | |
const [hoveredHour, setHoveredHour] = React.useState<number | null>(null); | |
const [isOptimalZoneHovered, setIsOptimalZoneHovered] = React.useState(false); | |
const [tooltipHoveredDate, setTooltipHoveredDate] = React.useState<Date | null>(null); | |
// Convert 24h to 12h format with minutes | |
const formatTime = (date: Date) => { | |
return date.toLocaleTimeString("en-US", { | |
hour: "numeric", | |
minute: "2-digit", | |
hour12: true, | |
}); | |
}; | |
// Get normalized hour (0-24 range) | |
const getNormalizedHour = (date: Date) => { | |
let hours = date.getHours() + date.getMinutes() / 60; | |
// If hour is between 0-14 (2PM), add 24 to keep it in sequence | |
if (hours < 14) hours += 24; | |
return hours; | |
}; | |
// Bounds | |
const xMax = width - margin.left - margin.right; | |
const yMax = height - margin.top - margin.bottom; | |
// Align dates to start of day | |
const alignToDay = (date: Date) => { | |
const d = new Date(date); | |
d.setHours(0, 0, 0, 0); | |
return d; | |
}; | |
const getXDomain = () => { | |
const [start, end] = extent(data, (d: SleepData) => alignToDay(d.bedtime_start)) as [Date, Date]; | |
// Add 12 hours padding to start and end to prevent clipping and overlap of the bars | |
const paddedStart = new Date(start); | |
paddedStart.setHours(paddedStart.getHours() - 12); | |
const paddedEnd = new Date(end); | |
paddedEnd.setHours(paddedEnd.getHours() + 12); | |
return [paddedStart, paddedEnd] as [Date, Date]; | |
}; | |
// Scales | |
const xScale = scaleTime({ | |
range: [0, xMax], | |
domain: getXDomain(), // Use padded domain | |
nice: false, | |
}); | |
const yScale = scaleLinear({ | |
range: [0, yMax], | |
domain: [22, 38], // From 10PM (22) to 2PM next day (38 = 24 + 14) | |
nice: true, | |
}); | |
const axisColor = "#fff"; | |
// Format hour labels for y-axis | |
const formatYAxisLabel = (value: NumberValue) => { | |
const hour = value.valueOf() as number; | |
const normalizedHour = hour > 24 ? hour - 24 : hour; | |
const date = new Date(2024, 0, 1, normalizedHour); | |
return date.toLocaleTimeString("en-US", { | |
hour: "numeric", | |
hour12: true, | |
}); | |
}; | |
// Filter out data points outside time range | |
const validData = data.filter((d) => { | |
const hour = getNormalizedHour(d.bedtime_start); | |
return hour !== null && hour >= 22 && hour <= 34; | |
}); | |
// Helper function to check if a time is within optimal bedtime zone | |
const isOptimalBedtime = (date: Date) => { | |
const hour = getNormalizedHour(date); | |
return hour !== null && hour >= 25.5 && hour <= 27.5; // Between 1:30 AM and 3:30 AM | |
}; | |
const formatDuration = (seconds: number) => { | |
const hours = Math.floor(seconds / 3600); | |
const minutes = Math.floor((seconds % 3600) / 60); | |
return `${hours}h ${minutes}m`; | |
}; | |
// Helper function to check if sleep duration is good (7 hours or more) | |
const isGoodDuration = (duration: number) => { | |
const sevenHoursInSeconds = 7 * 60 * 60; | |
return duration >= sevenHoursInSeconds; | |
}; | |
// Helper function to get bar color based on score, optimal time, and duration | |
const getBarColor = (date: Date, score: number, duration: number) => { | |
// First check for optimal time and duration for green | |
if (isOptimalBedtime(date) && isGoodDuration(duration)) { | |
return "color(display-p3 0.133 0.773 0.369)"; // Rich green | |
} | |
// Then check score ranges | |
if (score >= 90) { | |
return "color(display-p3 0.984 0.749 0.141)"; // Rich yellow gold | |
} | |
if (score >= 75) { | |
return "color(display-p3 0.537 0.129 0.878)"; // Light purple | |
} | |
if (score >= 66) { | |
return "color(display-p3 0.537 0.129 0.878)"; // Regular purple | |
} | |
if (score >= 55) { | |
return "color(display-p3 0.298 0.063 0.471)"; // Darker reddish purple | |
} | |
// Below 55 | |
return "color(display-p3 1 0.149 0.149)"; // Bright red | |
}; | |
// Calculate bar width based on date range and chart width | |
const getBarWidth = () => { | |
const dayWidth = xMax / validData.length; // Width available per day | |
switch (selectedRange) { | |
case "7D": | |
return dayWidth * 0.7; // 70% of day width | |
case "14D": | |
return dayWidth * 0.65; // 65% of day width | |
case "30D": | |
return dayWidth * 0.7; // 70% of day width | |
default: | |
return dayWidth * 0.7; | |
} | |
}; | |
const barWidth = getBarWidth(); | |
const isBarOverlappingHoveredHour = (start: Date, end: Date, hoveredHour: number | null) => { | |
if (hoveredHour === null) return false; | |
const startHour = getNormalizedHour(start); | |
const endHour = getNormalizedHour(end); | |
return hoveredHour >= startHour && hoveredHour <= endHour; | |
}; | |
const formatHoursMinutes = (seconds: number) => { | |
const hours = Math.floor(seconds / 3600); | |
const minutes = Math.floor((seconds % 3600) / 60); | |
return `${hours}:${minutes.toString().padStart(2, "0")}`; | |
}; | |
// Modify border radius of the bars based on selected range | |
const getBorderRadius = () => { | |
switch (selectedRange) { | |
case "7D": | |
return 12; | |
case "14D": | |
return 9; | |
case "30D": | |
return 6; | |
default: | |
return 12; | |
} | |
}; | |
const getDurationColor = (duration: number) => { | |
const hours = duration / 3600; // Convert seconds to hours | |
if (hours < 4.5) return "color(display-p3 1 0.149 0.149)"; // Red | |
if (hours < 6) return "color(display-p3 1 0.533 0)"; // Orange | |
if (hours < 7) return "color(display-p3 0.686 0.329 0.918)"; // Lighter purple | |
return "color(display-p3 0.133 0.773 0.369)"; // Green | |
}; | |
const hideTooltipTimeout = React.useRef<NodeJS.Timeout>(); | |
const showTooltip = (d: SleepData) => { | |
// Clear any pending hide timeout | |
if (hideTooltipTimeout.current) { | |
clearTimeout(hideTooltipTimeout.current); | |
} | |
setTooltipData({ | |
date: d.bedtime_start, | |
efficiency: d.efficiency, | |
bedtime_end: d.bedtime_end, | |
duration: d.duration, | |
score: d.score, | |
lowest_heart_rate: d.lowest_heart_rate, | |
average_hrv: d.average_hrv, | |
}); | |
setTooltipHoveredDate(d.bedtime_start); | |
}; | |
// Hide tooltip after a delay to prevent flickering | |
// when quickly hovering over other bars in a sequence | |
const hideTooltip = () => { | |
hideTooltipTimeout.current = setTimeout(() => { | |
setTooltipData(null); | |
setTooltipHoveredDate(null); | |
}, 150); | |
}; | |
return ( | |
<div className="relative"> | |
<svg width={width} height={height}> | |
<defs> | |
<linearGradient id="barGradient" x1="0" x2="0" y1="0" y2="1"> | |
<stop offset="0%" stopColor="white" stopOpacity="0.1" /> | |
<stop offset="100%" stopColor="white" stopOpacity="0" /> | |
</linearGradient> | |
</defs> | |
<Group left={margin.left} top={margin.top}> | |
{/* Optimal bedtime label with exit animation */} | |
<AnimatePresence> | |
{isOptimalZoneHovered && ( | |
<motion.g | |
initial={{ opacity: 0, y: 5 }} | |
animate={{ opacity: 1, y: 0 }} | |
exit={{ opacity: 0, y: 0 }} | |
transition={{ duration: 0.2 }} | |
> | |
<text | |
x={xMax / 2} | |
y={yScale(25.5) - 8} | |
textAnchor="middle" | |
fill="color(display-p3 0.133 0.773 0.369)" | |
fontSize="10px" | |
fontWeight="500" | |
style={{ | |
pointerEvents: "none", | |
textShadow: "0 1px 3px rgba(0,0,0,0.5)", | |
}} | |
> | |
OPTIMAL BEDTIME | |
</text> | |
</motion.g> | |
)} | |
</AnimatePresence> | |
{/* Green optimal zone highlight */} | |
<rect | |
x={0} | |
y={yScale(25.5)} | |
width={xMax} | |
height={Math.abs(yScale(27.5) - yScale(25.5))} | |
fill="color(display-p3 0.133 0.773 0.369)" | |
opacity={0.15} | |
rx={6} | |
ry={6} | |
style={{ | |
pointerEvents: "all", | |
cursor: "pointer", | |
transition: "opacity 250ms ease", | |
}} | |
onMouseEnter={() => setIsOptimalZoneHovered(true)} | |
onMouseLeave={() => setIsOptimalZoneHovered(false)} | |
/> | |
<Grid | |
xScale={xScale} | |
yScale={yScale} | |
width={xMax * 0.975} | |
height={yMax} | |
numTicksRows={12} | |
numTicksColumns={0} | |
strokeOpacity={0.05} | |
stroke={axisColor} | |
strokeWidth={1} | |
left={xMax * 0.0125} | |
style={{ pointerEvents: "none" }} | |
rowLineStyle={{ | |
strokeOpacity: 0.05, | |
stroke: axisColor, | |
}} | |
/> | |
{/* 11AM highlight line */} | |
<line | |
x1={xMax * 0.0125} // 1.25% from left | |
x2={xMax * 0.9875} // 98.75% of width | |
y1={yScale(35)} | |
y2={yScale(35)} | |
stroke="white" | |
strokeWidth={1} | |
strokeOpacity={0.2} | |
strokeDasharray="4 4" | |
/> | |
{/* Hovering over an hour highlights a horizontal line */} | |
{hoveredHour !== null && ( | |
<motion.line | |
initial={{ opacity: 0 }} | |
animate={{ opacity: 0.5 }} | |
transition={{ duration: 0.25 }} | |
x1={xMax * 0.0125} // 1.25% from left | |
x2={xMax * 0.9875} // 98.75% of width | |
y1={yScale(hoveredHour)} | |
y2={yScale(hoveredHour)} | |
stroke="white" | |
strokeWidth={1} | |
style={{ pointerEvents: "none" }} | |
/> | |
)} | |
<AxisLeft | |
scale={yScale} | |
stroke="transparent" | |
tickStroke="transparent" | |
tickFormat={formatYAxisLabel} | |
numTicks={9} | |
tickValues={[22, 24, 26, 28, 30, 32, 34, 36, 38]} | |
tickLabelProps={(value) => ({ | |
fill: axisColor, | |
fontSize: 11, | |
textAnchor: "end", | |
dx: -12, | |
dy: 4, | |
alignmentBaseline: "middle", | |
opacity: hoveredHour === value.valueOf() ? 1 : 0.3, | |
cursor: "pointer", | |
style: { transition: "opacity 250ms ease" }, | |
onMouseEnter: () => setHoveredHour(value.valueOf()), | |
onMouseLeave: () => setHoveredHour(null), | |
})} | |
/> | |
<AxisBottom | |
top={yMax} | |
scale={xScale} | |
stroke="transparent" | |
tickStroke="transparent" | |
tickFormat={(date) => { | |
const d = date as Date; | |
return d | |
.toLocaleDateString("en-US", { | |
weekday: "short", | |
}) | |
.charAt(0) | |
.toUpperCase(); | |
}} | |
numTicks={validData.length} | |
tickLabelProps={(value) => { | |
const date = value as Date; | |
const isWeekend = date.getDay() === 0 || date.getDay() === 6; // 0 is Sunday, 6 is Saturday | |
return { | |
fill: axisColor, | |
fontSize: 11, | |
textAnchor: "middle", | |
dy: 8, | |
opacity: isWeekend ? 0.3 : 1, // Dim the weekend labels on X axis | |
}; | |
}} | |
/> | |
{/* Sleep duration bars */} | |
{validData.map((d, i) => ( | |
<g key={i}> | |
<rect | |
x={xScale(alignToDay(d.bedtime_start)) - barWidth / 2} | |
y={yScale(getNormalizedHour(d.bedtime_start))} | |
width={barWidth} | |
height={yScale(getNormalizedHour(d.bedtime_end)) - yScale(getNormalizedHour(d.bedtime_start))} | |
rx={getBorderRadius()} | |
ry={getBorderRadius()} | |
fill={getBarColor(d.bedtime_start, d.score, d.duration)} | |
opacity={ | |
hoveredHour !== null | |
? isBarOverlappingHoveredHour(d.bedtime_start, d.bedtime_end, hoveredHour) | |
? 1 | |
: 0.1 | |
: isOptimalZoneHovered | |
? isOptimalBedtime(d.bedtime_start) && isGoodDuration(d.duration) | |
? 1 | |
: 0.1 | |
: tooltipHoveredDate | |
? alignToDay(d.bedtime_start).getTime() === alignToDay(tooltipHoveredDate).getTime() | |
? 1 | |
: 0.5 | |
: 1 | |
} | |
style={{ | |
cursor: "pointer", | |
transition: "opacity 350ms ease", | |
}} | |
onMouseEnter={() => showTooltip(d)} | |
onMouseLeave={hideTooltip} | |
/> | |
{/* Subtle gradient overlay on bars */} | |
<rect | |
x={xScale(alignToDay(d.bedtime_start)) - barWidth / 2} | |
y={yScale(getNormalizedHour(d.bedtime_start))} | |
width={barWidth} | |
height={yScale(getNormalizedHour(d.bedtime_end)) - yScale(getNormalizedHour(d.bedtime_start))} | |
rx={getBorderRadius()} | |
ry={getBorderRadius()} | |
fill="url(#barGradient)" | |
style={{ pointerEvents: "none" }} | |
opacity={ | |
hoveredHour !== null | |
? isBarOverlappingHoveredHour(d.bedtime_start, d.bedtime_end, hoveredHour) | |
? 1 | |
: 0.1 | |
: isOptimalZoneHovered | |
? isOptimalBedtime(d.bedtime_start) && isGoodDuration(d.duration) | |
? 1 | |
: 0.1 | |
: tooltipHoveredDate | |
? alignToDay(d.bedtime_start).getTime() === alignToDay(tooltipHoveredDate).getTime() | |
? 1 | |
: 0.5 | |
: 1 | |
} | |
/> | |
{/* Inset ring on bars */} | |
<rect | |
x={xScale(alignToDay(d.bedtime_start)) - barWidth / 2 + 1} | |
y={yScale(getNormalizedHour(d.bedtime_start)) + 1} | |
width={barWidth - 2} | |
height={yScale(getNormalizedHour(d.bedtime_end)) - yScale(getNormalizedHour(d.bedtime_start)) - 2} | |
rx={Math.max(0, getBorderRadius() - 1)} | |
ry={Math.max(0, getBorderRadius() - 1)} | |
fill="none" | |
stroke="white" | |
strokeWidth={1} | |
strokeOpacity={0.25} | |
style={{ pointerEvents: "none", mixBlendMode: "overlay" }} | |
opacity={ | |
hoveredHour !== null | |
? isBarOverlappingHoveredHour(d.bedtime_start, d.bedtime_end, hoveredHour) | |
? 1 | |
: 0.1 | |
: isOptimalZoneHovered | |
? isOptimalBedtime(d.bedtime_start) && isGoodDuration(d.duration) | |
? 1 | |
: 0.1 | |
: tooltipHoveredDate | |
? alignToDay(d.bedtime_start).getTime() === alignToDay(tooltipHoveredDate).getTime() | |
? 1 | |
: 0.5 | |
: 1 | |
} | |
/> | |
{/* Score text */} | |
<text | |
x={xScale(alignToDay(d.bedtime_start))} | |
y={yScale( | |
getNormalizedHour(d.bedtime_start) + | |
(getNormalizedHour(d.bedtime_end) - getNormalizedHour(d.bedtime_start)) / 2 | |
)} | |
textAnchor="middle" | |
dy=".3em" | |
fill="white" | |
fontSize="8px" | |
fontWeight="bold" | |
style={{ | |
pointerEvents: "none", | |
textShadow: "0 1px 3px rgba(0,0,0,0.5)", | |
}} | |
> | |
{d.score} | |
</text> | |
{/* Sleep duration text above bar */} | |
<text | |
x={xScale(alignToDay(d.bedtime_start))} | |
y={yScale(getNormalizedHour(d.bedtime_start)) - 6} | |
textAnchor="middle" | |
fill="white" | |
fontSize="8px" | |
opacity={ | |
tooltipHoveredDate && | |
alignToDay(d.bedtime_start).getTime() === alignToDay(tooltipHoveredDate).getTime() | |
? 1 | |
: 0.5 | |
} | |
style={{ | |
pointerEvents: "none", | |
textShadow: "0 1px 3px rgba(0,0,0,0.5)", | |
transition: "opacity 350ms ease", | |
}} | |
> | |
{formatHoursMinutes(d.duration)} | |
</text> | |
</g> | |
))} | |
</Group> | |
</svg> | |
{tooltipData && ( | |
<Tooltip | |
style={{ | |
...defaultStyles, | |
backgroundColor: "rgba(0,0,0,0.8)", | |
boxShadow: "0 0 15px 10px rgba(0,0,0,0.5)", | |
backdropFilter: "blur(8px)", | |
WebkitBackdropFilter: "blur(8px)", | |
color: "white", | |
border: "1px solid rgba(255,255,255,0.1)", | |
borderRadius: "12px", | |
padding: "12px 16px", | |
width: 220, | |
zIndex: 50, | |
}} | |
top={margin.top + yScale(getNormalizedHour(tooltipData.date) ?? 22) - 10} | |
left={margin.left + xScale(tooltipData.date) + barWidth / 2 + 6} | |
> | |
<div className="text-sm"> | |
<div className="text-xs opacity-40 uppercase tracking-wider"> | |
{tooltipData.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} | |
</div> | |
<div className="text-sm flex items-center gap-1 py-1.5"> | |
<Moon className="w-4 h-4 stroke-[2] text-[color(display-p3_0.537_0.129_0.878)]" /> | |
<span | |
style={{ | |
color: | |
getNormalizedHour(tooltipData.date) >= 28 // 4AM = 24 + 4 = 28 | |
? "color(display-p3 1 0.533 0)" | |
: "inherit", | |
}} | |
> | |
{formatTime(tooltipData.date)} | |
</span> | |
{" — "} | |
{formatTime(tooltipData.bedtime_end)} | |
<Sun className="w-4 h-4 stroke-[2] text-[color(display-p3_0.984_0.749_0.141)]" /> | |
</div> | |
<div | |
className="font-semibold mb-1 pb-2 border-b border-white/10 flex items-center gap-1" | |
style={{ color: getDurationColor(tooltipData.duration) }} | |
> | |
{formatDuration(tooltipData.duration)} | |
</div> | |
<div className="grid grid-cols-2 gap-2 py-1"> | |
<div> | |
<div className="text-lg font-bold text-white">{tooltipData.score}</div> | |
<div className="text-[10px] tracking-wider opacity-40 -mt-1.5">SCORE</div> | |
</div> | |
<div> | |
<div className="text-lg font-bold text-white">{tooltipData.efficiency}%</div> | |
<div className="text-[10px] tracking-wider opacity-40 -mt-1.5">EFFICIENCY</div> | |
</div> | |
</div> | |
<div className="grid grid-cols-2 gap-2 pt-2 mt-1 border-t border-white/10"> | |
<div> | |
<div className="text-lg font-bold text-white flex items-center gap-1"> | |
<Heart className="w-4 h-4 stroke-[2.5] text-[color(display-p3_1_0.412_0.706)]" /> {/* Pink */} | |
{tooltipData.lowest_heart_rate} | |
</div> | |
<div className="text-[10px] tracking-wider opacity-40 -mt-1.5">RESTING HR</div> | |
</div> | |
<div> | |
<div className="text-lg font-bold text-white flex items-center gap-1"> | |
<Activity className="w-4 h-4 stroke-[2.5] text-[color(display-p3_0_0.749_1)]" /> {/* Blue */} | |
{Math.round(tooltipData.average_hrv)} | |
</div> | |
<div className="text-[10px] tracking-wider opacity-40 -mt-1.5">AVG HRV</div> | |
</div> | |
</div> | |
</div> | |
</Tooltip> | |
)} | |
{/* Range selector tabs below the chart */} | |
<div className="flex items-center justify-center w-full h-16"> | |
<div className="flex gap-2"> | |
<button | |
onClick={() => onRangeChange("7D")} | |
className={tw( | |
"relative rounded-full px-3 py-1 text-xs font-medium text-white opacity-30 outline-none transition hover:opacity-100 flex items-center gap-2", | |
selectedRange === "7D" && "opacity-100" | |
)} | |
> | |
{selectedRange === "7D" && ( | |
<motion.span | |
layoutId="sleep-bubble" | |
className="absolute inset-0 bg-white/10 mix-blend-difference" | |
style={{ borderRadius: 9999 }} | |
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }} | |
/> | |
)} | |
7D | |
</button> | |
<button | |
onClick={() => onRangeChange("14D")} | |
className={tw( | |
"relative rounded-full px-3 py-1 text-xs font-medium text-white opacity-30 outline-none transition hover:opacity-100 flex items-center gap-2", | |
selectedRange === "14D" && "opacity-100" | |
)} | |
> | |
{selectedRange === "14D" && ( | |
<motion.span | |
layoutId="sleep-bubble" | |
className="absolute inset-0 bg-white/10 mix-blend-difference" | |
style={{ borderRadius: 9999 }} | |
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }} | |
/> | |
)} | |
14D | |
</button> | |
<button | |
onClick={() => onRangeChange("30D")} | |
className={tw( | |
"relative rounded-full px-3 py-1 text-xs font-medium text-white opacity-30 outline-none transition hover:opacity-100 flex items-center gap-2", | |
selectedRange === "30D" && "opacity-100" | |
)} | |
> | |
{selectedRange === "30D" && ( | |
<motion.span | |
layoutId="sleep-bubble" | |
className="absolute inset-0 bg-white/10 mix-blend-difference" | |
style={{ borderRadius: 9999 }} | |
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }} | |
/> | |
)} | |
30D | |
</button> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
export function SleepChart({ data }: { data: SleepData[] }) { | |
const [selectedRange, setSelectedRange] = React.useState<DateRange>("14D"); | |
// Filter data based on selected range | |
const filteredData = React.useMemo(() => { | |
const now = new Date(); | |
const daysAgo = parseInt(selectedRange); | |
const startDate = new Date(now.setDate(now.getDate() - daysAgo)); | |
return data.filter((d) => d.bedtime_start >= startDate); | |
}, [data, selectedRange]); | |
return ( | |
<div style={{ width: "100%", height: 300 }} className="relative pb-8 pt-8"> | |
<ParentSize> | |
{({ width, height }: { width: number; height: number }) => ( | |
<BaseChart | |
data={filteredData} | |
width={width} | |
height={height} | |
selectedRange={selectedRange} | |
onRangeChange={setSelectedRange} | |
/> | |
)} | |
</ParentSize> | |
</div> | |
); | |
} |
"use client"; | |
import { useOuraSleep } from "@/app/hooks/sleep"; | |
import { SleepChart } from "./sleep-chart"; | |
import { motion } from "framer-motion"; | |
function SleepChartSkeleton() { | |
return ( | |
<div className="w-full h-[300px] pt-8 pb-8 relative"> | |
{/* Y-axis labels skeleton */} | |
<div className="absolute left-3 top-12 bottom-0 w-12 flex flex-col gap-4"> | |
{Array.from({ length: 9 }).map((_, i) => ( | |
<div key={i} className="h-2 w-7 bg-white/5 rounded-full" /> | |
))} | |
</div> | |
{/* Bars skeleton */} | |
<div className="absolute left-16 right-4 top-8 bottom-8 flex items-end"> | |
{Array.from({ length: 14 }).map((_, i) => ( | |
<motion.div | |
key={i} | |
className="flex-1 mx-1" | |
initial={{ opacity: 0.5 }} | |
animate={{ opacity: 0.1 }} | |
transition={{ | |
duration: 0.8, | |
repeat: Infinity, | |
repeatType: "reverse", | |
delay: i * 0.1, | |
}} | |
> | |
<div | |
className="w-full bg-white/10 rounded-xl" | |
style={{ | |
height: `${Math.random() * 40 + 30}%`, | |
}} | |
/> | |
</motion.div> | |
))} | |
</div> | |
{/* X-axis labels skeleton */} | |
<div className="absolute left-14 right-4 bottom-5 flex"> | |
{Array.from({ length: 14 }).map((_, i) => ( | |
<div key={i} className="flex-1 flex justify-center"> | |
<div className="h-4 w-4 bg-white/5 rounded-full" /> | |
</div> | |
))} | |
</div> | |
</div> | |
); | |
} | |
export function Sleep() { | |
const { sleep, isLoading: sleepIsLoading } = useOuraSleep(); | |
return ( | |
<div className="w-full max-w-3xl mx-auto text-white select-none"> | |
{sleepIsLoading ? <SleepChartSkeleton /> : <SleepChart data={sleep} />} | |
</div> | |
); | |
} |