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> | |
| ); | |
| } |