2:47 AM, Googling "sleep calculator"
If you've ever had to be somewhere at 6 AM and couldn't do the mental math on which bedtime actually lines up with a full sleep cycle, you've hit the same wall I did: every sleep calculator on page one of Google feels like a heavy content farm wearing a calculator as a disguise.
Scroll past a wall of repetitive "SEO-optimized" text, dodge newsletter modals, and finally find the input field. Type in a wake time, only to wait for a full page reload because the "calculator" is running a legacy form POST on a server.
All I wanted was simple: if I fall asleep at 11:45 PM, when should my alarm go off so I wake up during light sleep instead of groggily fighting through deep sleep?
That's a basic 90-minute math problem. It does not need a server round-trip, heavy tracking bundles, or bloated layout shifts.
So the Sleep Cycle Calculator was built for HypeCalc with a clear focus: pure math, typed React state, zero layout shifts, and an engineering-led SEO layer built directly into Next.js. Here is how it works under the hood.
The Sleep Science, Reduced to a Formula
Sleep isn't a single uniform state — your brain cycles through stages roughly every 90 minutes: light sleep (N1, N2), deep slow-wave sleep (N3), and REM. Waking up mid-cycle (especially during N3) triggers sleep inertia — that heavy, groggy feeling. Waking up at a cycle boundary during light sleep leaves you far more alert.
The calculator calculates backward from a target wake time:
Bedtime Target = Desired Wake Time − (90 minutes × Number of Cycles) − Sleep Latency
Where:
- 90 minutes is the average full sleep cycle length.
- Number of Cycles is typically 4, 5, or 6 (5–6 cycles represent the restorative sweet spot for most adults).
- Sleep Latency is the average time to fall asleep once in bed (10–20 minutes; ignoring this is the most common error in manual sleep math).
Run it the other direction (given a bedtime, when should you wake up?):
Wake Time Target = Bedtime + Sleep Latency + (90 minutes × Number of Cycles)
The TypeScript Core: Time Math Without Date Libraries
Time arithmetic easily invites bugs (midnight rollovers, 12-hour AM/PM formatting). I isolated the logic into a pure, dependency-free utility:
// lib/calculateSleepCycles.ts
const CYCLE_LENGTH_MINUTES = 90;
const DEFAULT_LATENCY_MINUTES = 15;
const CYCLE_OPTIONS = [3, 4, 5, 6] as const;
export interface SleepCycleOption {
cycles: number;
totalSleepMinutes: number;
targetTime: string;
isRecommended: boolean;
}
export interface SleepCalculationInput {
anchorTime: string; // "HH:MM" 24hr format
mode: "wakeUp" | "bedtime";
latencyMinutes?: number;
}
export function calculateSleepCycles({
anchorTime,
mode,
latencyMinutes = DEFAULT_LATENCY_MINUTES,
}: SleepCalculationInput): SleepCycleOption[] {
const anchorDate = parseTimeToDate(anchorTime);
return CYCLE_OPTIONS.map((cycles) => {
const cycleMinutes = cycles * CYCLE_LENGTH_MINUTES;
const offsetMinutes =
mode === "wakeUp"
? -(cycleMinutes + latencyMinutes)
: cycleMinutes + latencyMinutes;
const targetDate = addMinutes(anchorDate, offsetMinutes);
return {
cycles,
totalSleepMinutes: cycleMinutes,
targetTime: formatTime(targetDate),
isRecommended: cycles === 5 || cycles === 6,
};
}).reverse();
}
function parseTimeToDate(time: string): Date {
const [hours, minutes] = time.split(":").map(Number);
const date = new Date();
date.setHours(hours, minutes, 0, 0);
return date;
}
function addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60_000);
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
Key architectural decisions:
-
Zero date library bloat: Native
DateandtoLocaleTimeStringhandle 12-hour formatting in a few lines without adding package weight. -
Separation of concerns: Business rules (
isRecommended) remain in the calculation layer rather than being scattered across JSX conditionals.
The Client Component: Fast, Reactive State
Instead of separate components, a single state machine handles both wake-up and bedtime directions seamlessly:
// CalculatorForm.tsx
"use client";
import { useMemo, useState } from "react";
import {
calculateSleepCycles,
type SleepCycleOption,
} from "@/lib/calculateSleepCycles";
export default function CalculatorForm() {
const [mode, setMode] = useState<"wakeUp" | "bedtime">("wakeUp");
const [anchorTime, setAnchorTime] = useState("06:00");
const options: SleepCycleOption[] = useMemo(
() => calculateSleepCycles({ anchorTime, mode }),
[anchorTime, mode]
);
return (
<div className="flex flex-col gap-5">
<div className="flex rounded-xl bg-zinc-100 p-1 text-sm font-semibold">
<button
type="button"
onClick={() => setMode("wakeUp")}
className={`flex-1 rounded-lg px-3 py-2 transition-colors ${
mode === "wakeUp" ? "bg-white shadow-sm text-indigo-600" : "text-zinc-500"
}`}
>
I need to wake up at...
</button>
<button
type="button"
onClick={() => setMode("bedtime")}
className={`flex-1 rounded-lg px-3 py-2 transition-colors ${
mode === "bedtime" ? "bg-white shadow-sm text-indigo-600" : "text-zinc-500"
}`}
>
I'm going to bed at...
</button>
</div>
<label className="flex flex-col gap-1 text-sm text-zinc-500">
{mode === "wakeUp" ? "Wake-up time" : "Bedtime"}
<input
type="time"
value={anchorTime}
onChange={(e) => setAnchorTime(e.target.value)}
className="rounded-lg border border-zinc-200 px-3 py-2 text-lg font-medium text-zinc-900 outline-none focus:border-indigo-500"
/>
</label>
<ul className="flex flex-col gap-2">
{options.map((opt) => (
<li
key={opt.cycles}
className={`flex items-center justify-between rounded-lg border px-4 py-3 ${
opt.isRecommended
? "border-indigo-200 bg-indigo-50"
: "border-zinc-100 bg-white"
}`}
>
<span className="font-mono text-lg font-bold text-zinc-900">
{opt.targetTime}
</span>
<span className="text-sm text-zinc-500">
{opt.cycles} cycles · {(opt.totalSleepMinutes / 60).toFixed(1)}h
{opt.isRecommended && (
<span className="ml-2 rounded-full bg-indigo-600 px-2 py-0.5 text-xs font-semibold text-white">
Best
</span>
)}
</span>
</li>
))}
</ul>
</div>
);
}
Server-Rendered JSON-LD & Mobile-First UX
To ensure crawlers read structural data instantly without executing heavy client bundles, JSON-LD schema is injected server-side:
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "How long is one sleep cycle?",
"acceptedAnswer": {
"@type": "Answer",
"text": "A standard human sleep cycle lasts roughly 90 minutes, alternating between non-REM and REM sleep."
}
}
]
};
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
Mobile-First Grid Hierarchy
On desktop, the layout renders a responsive two-column grid. On mobile, order-1 forces the interactive calculator to load above the informational content, eliminating unnecessary scrolling for late-night mobile users.
Performance Benchmark
| Metric | Legacy Web Calculators | HypeCalc Architecture |
|---|---|---|
| Cumulative Layout Shift (CLS) | High (0.2+) | 0.00 |
| Execution Model | Server POST reload | Client-side memoized state |
| Time to Calculation | 2–4s per update | Sub-millisecond |
| Schema Delivery | Client tag-manager scripts | Direct SSR JSON-LD |
Check Out the Tool
The live calculator is available here: HypeCalc Sleep Cycle Calculator →
Discussion Points
- Native Date vs. Libraries: For simple time offsets and local formatting, is raw JavaScript cleaner than bundling date-fns/dayjs?
- Schema Integrity: How do you approach server-side structured data without overloading initial payloads?













