Skip to content

Instantly share code, notes, and snippets.

@pugson
Created November 11, 2024 21:54
Show Gist options
  • Save pugson/e721a163dee7d2ddb2f5f258de5b8213 to your computer and use it in GitHub Desktop.
Save pugson/e721a163dee7d2ddb2f5f258de5b8213 to your computer and use it in GitHub Desktop.
Interactive component showing sleep data from Oura Ring API

Preview

preview

See the demo on Twitter

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>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment