Menstrual Cycle Tracker with Ovulation Days from Daily Notes

Hello those with menstrual cycles needing a privacy focused way of tracking! I didn’t see any on here so I’d figured I’d contribute my own .mds.

I use this to view my own cycle by logging in values in my daily notes (in DailyNotes/Files)! This is free for you all to use, enjoy!

p= period, when your cycle starts you tag p in that daily note as #start, and once it ends, you can tag that last day as #end. Each day you can add symptoms such as #light #heavy, #medium, #disabling. Read the Severity Weights in this file and feel free to add your own!

m= mittelschmerz, if I can tell when that is, I mark that as any value in Daily Notes (I just put an ‘x’), otherwise there is a manual number listed in this section:

// manual override: days after P start
const MANUAL_M_OFFSET = 16;

Change this as needed.

I used numerical values to gauge how severe each period was per tagged symptom.

Code for dataview:


// =======================================================
// CONFIG
// =======================================================
const FOLDER = "DailyNotes/Files";
const FIELD = "p";
const M_FIELD = "m";

// manual override: days after P start
const MANUAL_M_OFFSET = 16;

// severity weights
const severityScores = {
  "#light": 1,
  "#medium": 2,
  "#heavy": 3,
  "#spotting": 1,
  "#pain1": 1,
  "#pain2": 2,
  "#pain3": 3,
  "#disabling": 4,
  "#missedWork": 5,
  "#chills": 3,
  "#dizzy": 3
};

// =======================================================
// LOAD & NORMALIZE ENTRIES
// =======================================================
let pages = dv.pages(`"${FOLDER}"`).where(p => (p[FIELD] || p[M_FIELD]) && p.date);

let entries = pages.array().map(p => {
  let raw = p[FIELD];
  let tagText = Array.isArray(raw) ? raw.join(" ") : String(raw || "");
  let tags = tagText.split(/\s+/).filter(t => t.startsWith("#"));

  let severity = tags.reduce((s, t) => s + (severityScores[t] || 0), 0);
  let date = p.date.toJSDate ? p.date.toJSDate() : new Date(p.date);

  return {
    file: p.file,
    date,
    tags,
    severity,
    m: p[M_FIELD] || null
  };
});

entries.sort((a, b) => a.date - b.date);

// =======================================================
// BUILD P CYCLES
// =======================================================
let starts = entries.filter(e => e.tags.includes("#start"));
let ends   = entries.filter(e => e.tags.includes("#end"));

let cycles = [];
let remainingEnds = [...ends];

for (let s of starts) {
  let idx = remainingEnds.findIndex(e => e.date >= s.date);

  let endDate = idx !== -1
    ? remainingEnds[idx].date
    : entries[entries.length - 1].date;

  let cycleEntries = entries.filter(e => e.date >= s.date && e.date <= endDate);

  cycles.push({
    start: s.date,
    end: endDate,
    duration: Math.round((endDate - s.date) / 86400000),
    severity: cycleEntries.reduce((a, e) => a + e.severity, 0),
    disabling: cycleEntries.filter(e => e.tags.includes("#disabling")).length,
    missedWork: cycleEntries.filter(e => e.tags.includes("#missedWork")).length
  });

  if (idx !== -1) remainingEnds.splice(idx, 1);
}

// =======================================================
// AVERAGES
// =======================================================
let avgDuration = cycles.length
  ? cycles.reduce((a, c) => a + c.duration, 0) / cycles.length
  : null;

let gaps = [];
for (let i = 0; i < cycles.length - 1; i++) {
  gaps.push((cycles[i + 1].start - cycles[i].end) / 86400000);
}

let avgGap = gaps.length
  ? gaps.reduce((a, b) => a + b, 0) / gaps.length
  : null;

// =======================================================
// NEXT P PREDICTIONS
// =======================================================
let lastEnd = cycles.length ? cycles[cycles.length - 1].end : null;

let predictedStart = lastEnd && avgGap !== null
  ? new Date(lastEnd.getTime() + Math.round(avgGap) * 86400000)
  : null;

let predictedEnd = predictedStart && avgDuration !== null
  ? new Date(predictedStart.getTime() + Math.round(avgDuration) * 86400000)
  : null;

let lastStart = starts.length ? starts[starts.length - 1].date : null;
let predictedStart29 = lastStart
  ? new Date(lastStart.getTime() + 29 * 86400000)
  : null;

// =======================================================
// M EVENTS
// =======================================================
let pStarts = cycles.map(c => c.start).sort((a, b) => a - b);

function prevStart(d) {
  let r = null;
  for (let s of pStarts) if (s <= d) r = s;
  return r;
}

let mEvents = entries
  .filter(e => e.m)
  .map(e => {
    let s = prevStart(e.date);
    return {
      date: e.date,
      start: s ? s : null,
      day: s ? Math.floor((e.date - s) / 86400000) + 1 : null,
      value: e.m
    };
  });

// =======================================================
// AVERAGE DAY OF M
// =======================================================
let anchoredMEvents = mEvents.filter(e => e.day !== null);
let avgMDay = anchoredMEvents.length
  ? anchoredMEvents.reduce((sum, e) => sum + e.day, 0) / anchoredMEvents.length
  : null;

// =======================================================
// M PREDICTIONS (NEXT + CURRENT)
// =======================================================
let nextMFromAverage = (predictedStart && avgMDay !== null)
  ? new Date(predictedStart.getTime() + Math.round(avgMDay - 1) * 86400000)
  : null;

let nextMFromManual = predictedStart
  ? new Date(predictedStart.getTime() + MANUAL_M_OFFSET * 86400000)
  : null;

let currentMFromAverage = (lastStart && avgMDay !== null)
  ? new Date(lastStart.getTime() + Math.round(avgMDay - 1) * 86400000)
  : null;

let currentMFromManual = lastStart
  ? new Date(lastStart.getTime() + MANUAL_M_OFFSET * 86400000)
  : null;

// =======================================================
// OUTPUT
// =======================================================
dv.header(3, "Cycle Summary & Prediction");

dv.list([
  `Average cycle duration: **${avgDuration?.toFixed(1) ?? "N/A"} days**`,
  `Average gap: **${avgGap?.toFixed(1) ?? "N/A"} days**`,
  `Predicted next start (P): **${predictedStart?.toISOString().slice(0,10) ?? "N/A"}**`,
  `Predicted next start (P +29): **${predictedStart29?.toISOString().slice(0,10) ?? "N/A"}**`,
  `Predicted next end (P): **${predictedEnd?.toISOString().slice(0,10) ?? "N/A"}**`,
  `Next predicted M (average): **${nextMFromAverage?.toISOString().slice(0,10) ?? "N/A"}**`,
  `Next predicted M (+${MANUAL_M_OFFSET}): **${nextMFromManual?.toISOString().slice(0,10) ?? "N/A"}**`,
  `Current cycle M (average): **${currentMFromAverage?.toISOString().slice(0,10) ?? "N/A"}**`,
  `Current cycle M (+${MANUAL_M_OFFSET}): **${currentMFromManual?.toISOString().slice(0,10) ?? "N/A"}**`,
  `Cycles observed: **${cycles.length}**`,
  `Average day of m: **${avgMDay?.toFixed(1) ?? "N/A"}**`
]);

dv.header(4, "Cycle Breakdown");

dv.table(
  ["Start", "End", "Duration", "Severity", "Disabling", "MissedWork"],
  cycles.map(c => [
    c.start.toISOString().slice(0,10),
    c.end.toISOString().slice(0,10),
    c.duration,
    c.severity,
    c.disabling,
    c.missedWork
  ])
);

dv.header(4, "M Events");

dv.table(
  ["Date", "Cycle Start", "Day", "Value"],
  mEvents.map(e => [
    e.date.toISOString().slice(0,10),
    e.start ? e.start.toISOString().slice(0,10) : "N/A",
    e.day ?? "N/A",
    e.value
  ])
);



Code to add to Daily Notes:

---
tags:
  - daily 
date: <% tp.date.now("YYYY-MM-DD") %>

---

# <% tp.file.title %>

---
## PTracker
- m:: 
- p:: 
- psym:: 

---

4 Likes

I do have other metrics but those are quite specific and lengthy. You can use this for cycling biologic medications and its coorelation with symptoms for instance.

The community here seemed to have plenty of daily health trackers, so I contributed the mentrual tracker aspect for those who could copy/paste and didn’t want an app or corporate involvement into their periods. I searched and didn’t find anything suitable for tracking specifically menstruation!

2 Likes

I love this! I am having some issues with the code - does it just need copy/pasted from the top down to the Daily Notes part in between a set of backticks to work? When I do that it’s just showing the text that I put in, instead of generating any sort of dataview table so I’m wondering if I’m missing something!