DEV Community

Cover image for How I Built a Clean Sleep Calculator Using Next.js, TypeScript, and Sleep Cycle Math
G S
G S

Posted on

How I Built a Clean Sleep Calculator Using Next.js, TypeScript, and Sleep Cycle Math

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

Key architectural decisions:

  • Zero date library bloat: Native Date and toLocaleTimeString handle 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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) }}
/>
Enter fullscreen mode Exit fullscreen mode

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?

Top comments (2)

Collapse
 
celine_carter_f9e7f703c59 profile image
Celine Carter

Great project! I like how you combined the sleep-cycle math with a clean Next.js and TypeScript implementation. Keeping the calculator simple and practical makes it really useful.

Collapse
 
g_s_6bc9d3a878dd452010bec profile image
G S

Thanks so much! Really glad you liked the architecture and the straightforward approach.

My main goal was to strip away the bloated UI and unnecessary server reloads you typically see on utility sites and just let fast, typed client side math do the work. Appreciate the support!