Building Asante: A Perimenopause Tracker That Turns Symptoms Into Doctor-Ready Data

March 26, 2026 (3mo ago)

Your doctor has seven minutes. You've had symptoms for eighteen months. That's the gap asante.health is designed to close.

73% of women say their GP dismissed or minimized their perimenopause symptoms on the first appointment. The average time from first symptoms to getting the right support in the UK is 4.2 years. The problem isn't that doctors don't care - it's that subjective symptom reporting ("I've been having hot flashes") doesn't give them enough to work with.

Asante turns daily symptom logging into a structured, two-page appointment summary PDF. The appointment changes when the data is clear.

What Asante Does

The core loop is simple:

  1. Log - Tap to record hot flashes in real-time, review other symptoms at end of day
  2. See patterns - 90-day calendar with severity heatmap, Apple Health data overlay
  3. Generate report - Two-page PDF with frequencies, trends, correlations, and clinical context
  4. Hand to doctor - Structured data replaces vague descriptions

The free tier covers daily logging and the current month calendar. Pro ($6.99/month or $49.99/year) unlocks the 90-day calendar, Apple Health sync, unlimited PDF reports, and custom symptom tags.

The Tech Stack

Asante is a Bun monorepo with two apps:

Mobile (Expo + React Native)

  • Expo with file-based routing
  • WatermelonDB for offline-first local data
  • Zustand for lightweight state (tags, milestones, health data)
  • RevenueCat for in-app purchases
  • react-native-health-connect for Apple HealthKit + Android Health Connect
  • Reanimated for tactile animations

Backend (Next.js)

  • Next.js 15 for API routes and landing page
  • Supabase (PostgreSQL) with EU data residency
  • Drizzle ORM for type-safe queries
  • Better Auth for authentication
  • Browserless for PDF rendering
  • Supabase Storage for PDF files with signed URLs
  • Upstash for rate limiting
  • Resend for transactional email

Offline-First Architecture

The most important architectural decision: core logging works without internet. If a woman is having a hot flash at 3am with no signal, she should be able to tap the button and have it recorded with a precise timestamp. Sync happens later.

WatermelonDB for Structured Data

WatermelonDB handles the heavy lifting for day logs and hot flash events:

export const schema = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'day_logs',
      columns: [
        { name: 'date', type: 'string', isIndexed: true },
        { name: 'night_sweats', type: 'boolean', isOptional: true },
        { name: 'mood', type: 'string' },           // JSON array
        { name: 'sleep_rating', type: 'number', isOptional: true },
        { name: 'brain_fog', type: 'string', isOptional: true },
        { name: 'joint_pain', type: 'string', isOptional: true },
        { name: 'vaginal_dryness', type: 'string', isOptional: true },
        { name: 'energy_rating', type: 'number', isOptional: true },
        { name: 'notes', type: 'string' },
        { name: 'review_completed', type: 'boolean' },
        { name: 'created_at', type: 'number' },
        { name: 'updated_at', type: 'number' },
      ],
    }),
    tableSchema({
      name: 'hot_flash_events',
      columns: [
        { name: 'day_log_id', type: 'string', isIndexed: true },
        { name: 'date', type: 'string', isIndexed: true },
        { name: 'time', type: 'string' },
        { name: 'timestamp', type: 'number' },
        { name: 'created_at', type: 'number' },
        { name: 'updated_at', type: 'number' },
      ],
    }),
  ],
});

Each hot flash event gets a millisecond-precision timestamp. This isn't retrospective counting ("I had about 3 hot flashes today") - it's precise, timestamped records that can show time-of-day distribution patterns.

Opportunistic Sync

Sync is triggered on app foreground, connectivity restored, and after every local write (debounced by 2 seconds):

export async function syncWithSupabase(): Promise<void> {
  if (!SYNC_URL || !supabase || !database) return;
 
  const { data: { session } } = await supabase.auth.getSession();
  if (!session?.access_token) return;
 
  const netState = await NetInfo.fetch();
  if (!netState.isConnected) return;
 
  // If already syncing, queue one follow-up
  if (isSyncing) {
    pendingSync = true;
    return;
  }
  isSyncing = true;
 
  try {
    await synchronize({
      database,
      pullChanges: async ({ lastPulledAt }) => {
        const res = await fetch(`${SYNC_URL}/pull`, {
          method: 'POST',
          headers: { Authorization: `Bearer ${session.access_token}` },
          body: JSON.stringify({ lastPulledAt }),
        });
        return await res.json();
      },
      pushChanges: async ({ changes, lastPulledAt }) => {
        await fetch(`${SYNC_URL}/push`, {
          method: 'POST',
          headers: { Authorization: `Bearer ${session.access_token}` },
          body: JSON.stringify({ changes, lastPulledAt }),
        });
      },
    });
  } finally {
    isSyncing = false;
    if (pendingSync) {
      pendingSync = false;
      syncWithSupabase(); // Process queued sync
    }
  }
}

The pendingSync flag ensures that if a sync is already in progress when new data arrives, one follow-up sync is queued. Without this, rapid logging could trigger dozens of concurrent sync attempts.

Two-Tier Sync System

Not all data needs the full WatermelonDB sync protocol. I split data into two tiers:

  • Heavy data (day_logs, hot_flash_events): WatermelonDB sync protocol with pull/push, conflict resolution, and compression
  • Lightweight data (tags, milestones, health records): Direct Supabase upserts with 3-second debouncing
export function scheduleDataSync(): void {
  if (isSyncing) return;
  if (dataSyncTimer) clearTimeout(dataSyncTimer);
  dataSyncTimer = setTimeout(() => {
    dataSyncTimer = null;
    syncDataToSupabase();
  }, 3_000);
}

This avoids the overhead of the full sync protocol for simple key-value data while keeping the offline guarantee for the critical logging path.

The Tap-to-Log Mechanic

The home screen hot flash button is designed for one-tap logging with immediate haptic feedback:

const onLogHF = useCallback(() => {
  logHotFlash();
 
  // Satisfying bounce animation
  hfScale.value = withSequence(
    withTiming(1.12, { duration: 100 }),
    withTiming(1, { duration: 120 }),
  );
 
  // Medium haptic feedback
  Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
 
  // Schedule re-engagement notification if first log of session
  if (!reengagementScheduled.current) {
    reengagementScheduled.current = true;
    scheduleReengagement();
  }
}, []);

The addHotFlash method on the DayLog model creates a timestamped event:

@writer async addHotFlash() {
  const now = new Date();
  const time = now.toLocaleTimeString('en-GB', {
    hour: '2-digit',
    minute: '2-digit',
  });
  await this.collections.get<HotFlashEvent>('hot_flash_events').create((hf) => {
    hf.dayLog.set(this);
    hf.date = this.date;
    hf.time = time;
    hf.timestamp = now.getTime();
  });
}

The timestamps accumulate throughout the day. At the end, the report can show distribution: "Morning: 2, Afternoon: 3, Evening: 1, Night: 0" - data that's impossible to reconstruct from memory.

Apple HealthKit Integration

Sleep quality and HRV (heart rate variability) are strongly correlated with perimenopause symptoms. Instead of asking users to manually rate their sleep, I pull data from Apple Health:

async getSleepData(from: Date, to: Date): Promise<SleepRecord[]> {
  const hc = await getHealthConnect();
  if (!hc) return [];
 
  const result = await hc.readRecords('SleepSession', {
    timeRangeFilter: {
      operator: 'between',
      startTime: from.toISOString(),
      endTime: to.toISOString(),
    },
  });
 
  return (result.records || []).map((record: any) => {
    const hours = (new Date(record.endTime).getTime() - new Date(record.startTime).getTime()) / 3600000;
    return {
      date: formatDate(new Date(record.startTime)),
      hours: Math.round(hours * 10) / 10,
      startTime: record.startTime,
      endTime: record.endTime,
    };
  });
}

Sleep Session Aggregation

Sleep data is messy. A single night might have multiple sessions (woke up, fell back asleep) or a nap mixed in. The aggregation logic distinguishes main sleep from naps:

for (const [date, recs] of Object.entries(sleepByDate)) {
  if (recs.length === 1) {
    sleepMap[date] = recs[0].hours;
    continue;
  }
 
  let mainSleepHours = 0;
  let longestNap = 0;
 
  for (const rec of recs) {
    const startHour = new Date(rec.startTime).getHours();
    const isMainSleep = startHour >= 19 || startHour < 10;
    if (isMainSleep) {
      mainSleepHours += rec.hours;
    } else {
      longestNap = Math.max(longestNap, rec.hours);
    }
  }
 
  // Prefer main sleep total; fall back to longest nap if no main sleep
  sleepMap[date] = mainSleepHours > 0 ? mainSleepHours : longestNap;
}

Sessions starting between 19:00 and 10:00 count as main sleep. Everything else is a nap. This heuristic isn't perfect, but it handles the vast majority of cases correctly.

All health data types (sleep, HRV, resting heart rate, steps, body temperature, menstrual flow) are fetched in parallel on sync:

const [sleep, hr, hrv, menstrual, bodyTemp, steps] = await Promise.all([
  provider.getSleepData(from, to),
  provider.getRestingHeartRate(from, to),
  provider.getHRV(from, to),
  provider.getMenstrualData(from, to),
  provider.getBodyTemperature(from, to),
  provider.getSteps(from, to),
]);

The 90-Day Pattern Calendar

The calendar shows three complete months side-by-side with severity-coded days:

const SEVERITY_SCALE = [C.a0, C.a1, C.a2, C.a3, C.a4];
 
function computeSeverity(log: DayLog | undefined): number {
  if (!log || (!log.reviewCompleted && log.hotFlashes.length === 0)) return -1;
  const hf = log.hotFlashes.length;
  if (hf === 0) return 0;   // None - logged but no hot flashes
  if (hf <= 1) return 1;    // Mild
  if (hf <= 3) return 2;    // Moderate
  if (hf <= 5) return 3;    // High
  return 4;                  // Peak
}

Each day cell renders with:

  • Background color based on severity (5-level scale from none to peak)
  • Menstrual flow indicator (small pink dot)
  • HRT milestone markers
  • Logging streak underline
  • Today highlighted

The calendar builds 3 months backwards from today, computing the correct start-of-week offset for Monday-first weeks. Unlogged days appear neutral, distinguishing them from logged-but-symptom-free days.

PDF Report Generation

The report is the core value proposition. After 14+ days of logging, users generate a two-page appointment summary.

The Report Data Structure

The report aggregates 90 days of data into a clinical format:

interface ReportData {
  // Key metrics
  avgHFPerDay: string;     // "2.3"
  sleepQuality: string;    // "3.2/5"
  energyRating: string;    // "3.4/5"
  avgHRV?: string;         // "34ms"
 
  // Severity breakdowns
  brainFogLine: string;    // "23 of 78 days (29%) — 8 severe, 10 moderate, 5 mild"
  nightSweatsLine: string; // "43 of 78 days (55%)"
 
  // Time-of-day distribution
  hotFlashTimeOfDay: {
    morning: number;       // 05:00–11:59
    afternoon: number;     // 12:00–16:59
    evening: number;       // 17:00–20:59
    night: number;         // 21:00–04:59
  };
 
  // Mood distribution
  moodSummary: Array<{ mood: string; pct: number }>;
 
  // Best/worst weeks and trends
  bestWeekLine: string;
  worstWeekLine: string;
  weeklyHF: number[];      // 7 values for sparkline
 
  // Clinical advocacy
  findings: Array<{
    headline: string;
    detail: string;
    action: string;        // Suggested discussion point
  }>;
 
  // Health device data
  healthSummary?: {
    avgSleepHours: string;
    sleepSource: 'device' | 'rating' | 'none';
    sleepCorrelationInsight: string | null;
    avgHRV: string;
    avgRestingHR: string;
  };
 
  // Calendar heatmap for visual pattern
  calendarHeatmap: Array<{
    date: string;
    intensity: number;     // 0-4 severity
    logged: boolean;
  }>;
}

The Generation Pipeline

Report generation is async - the API returns immediately and the client polls:

export async function POST(request: Request) {
  const user = await getUserFromRequest(request);
  const { reportData } = await request.json();
 
  // Insert record immediately (status = pending)
  const [report] = await db.insert(reports).values({
    userId: user.id,
    periodStart: reportData.periodStart,
    periodEnd: reportData.periodEnd,
    reportData,
  }).returning();
 
  // Generate PDF after response is sent
  after(async () => {
    await db.update(reports).set({ status: 'generating' }).where(eq(reports.id, report.id));
 
    const html = buildReportHTML(reportData);
    const pdfBuffer = await htmlToPdf(html);
 
    // Upload to Supabase Storage
    await supabase.storage.from('reports').upload(`${user.id}/${report.id}.pdf`, pdfBuffer, {
      contentType: 'application/pdf',
      upsert: true,
    });
 
    await db.update(reports).set({ pdfUrl: storagePath, status: 'complete' }).where(eq(reports.id, report.id));
  });
 
  return NextResponse.json({ reportId: report.id, status: 'pending' });
}

The client polls every 2 seconds with a 60-second timeout:

export async function pollReport(reportId: string): Promise<{ pdfUrl: string }> {
  for (let i = 0; i < 30; i++) {
    const data = await fetch(`${API_URL}/api/reports/${reportId}`, { headers }).then(r => r.json());
 
    if (data.status === 'failed') throw new Error(data.error);
    if (data.status === 'complete' && data.pdfUrl) return { pdfUrl: data.pdfUrl };
 
    await new Promise(r => setTimeout(r, 2000));
  }
  throw new Error('Report generation timed out.');
}

Browserless renders the HTML to a pixel-perfect A4 PDF:

export async function htmlToPdf(html: string): Promise<Buffer> {
  const response = await fetch(`${BROWSERLESS_URL}/pdf`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      html,
      options: {
        format: 'A4',
        printBackground: true,
        margin: { top: '0mm', bottom: '0mm', left: '0mm', right: '0mm' },
      },
    }),
    signal: AbortSignal.timeout(60_000),
  });
 
  return Buffer.from(await response.arrayBuffer());
}

HRT Milestone Tracking

Perimenopause treatment changes (starting HRT, switching dosages, stopping) are critical context for the report. The milestone system stores treatment events:

interface Milestone {
  id: string;
  date: string;
  type: 'hrt_start' | 'hrt_change' | 'hrt_stop' | 'custom';
  label: string;     // "Started Estradiol 1mg", "Switched to patches"
  notes?: string;
}

Milestones appear on the calendar as markers and in the PDF report as context. A doctor seeing "Started HRT on Jan 15, hot flash frequency dropped 40% by Feb" gets a much clearer picture than "I started some medication a while ago and things might be better."

RevenueCat Paywall

The paywall triggers at the highest-intent moment - when a user tries to generate their first report:

const PRO_BENEFITS = [
  { icon: '📊', label: '90-day calendar patterns', sub: 'See the full picture' },
  { icon: '🔬', label: 'Sleep & symptom correlations', sub: 'Find your triggers' },
  { icon: '📋', label: 'Unlimited PDF summaries', sub: 'For every appointment' },
  { icon: '❤️', label: 'Health data sync', sub: 'Sleep, heart rate & HRV' },
  { icon: '🏷️', label: 'Custom symptom tags', sub: 'Track what matters to you' },
];
 
// Personalized hook — show HER data investment
const { daysLogged, totalHF } = useMemo(() => {
  const entries = Object.values(logs).filter(
    l => l.hotFlashes.length > 0 || l.reviewCompleted,
  );
  return {
    daysLogged: entries.length,
    totalHF: entries.reduce((s, d) => s + d.hotFlashes.length, 0),
  };
}, [logs]);

The paywall shows the user's own data ("You've logged 47 hot flashes over 23 days") as a commitment escalation - they've already invested the effort, the report is the payoff.

The 14-day free trial uses the Apple-endorsed timeline pattern:

  • Today: Full access starts
  • Day 12: Reminder notification
  • Day 14: Trial ends, cancel free anytime before

What I Learned

1. Offline-first is non-negotiable for health tracking. Symptoms happen at inconvenient times. If the app needs internet to record a 3am hot flash, it's useless. WatermelonDB's sync protocol handles conflict resolution gracefully, and the 2-second debounced sync keeps the server in sync without hammering the API.

2. Health data aggregation is harder than fetching. Getting data from HealthKit is straightforward. Making sense of it - grouping sleep sessions, distinguishing naps from main sleep, handling missing days - is where the complexity lives.

3. The report is the product, not the app. Users don't care about the calendar or the logging UI as ends in themselves. They care about walking into a doctor's appointment with credible data. Every feature decision filters through: "Does this make the report more useful?"

4. Personalized paywalls convert better. Showing the user's own logged data on the paywall ("47 hot flashes over 23 days - see the patterns") performs dramatically better than generic benefit lists. They've already done the work; the report is the reward.

The Name

Asante is named after Yaa Asantewaa (1840-1921), the Ashanti queen mother who led the War of the Golden Stool against British colonial forces. When the men in the room had given up, she said: "If you will not go forward, then we will. We, the women, will."

It's also Swahili for "thank you" - acknowledging the daily work women do logging their symptoms.

Try It

asante.health is currently in closed testing on Android (Google Play requires 14 days of closed testing before production release) and will be publicly available soon, with iOS following after. The free tier covers daily logging - start building your data today, and generate your first appointment summary when you're ready.


Building a health app or have questions about offline-first architecture? Find me on X or email [email protected].