2026-10-18 08:37:13 +01:00
( ( ) => {
const Cycle = window . Cycle ;
2026-10-18 09:32:29 +01:00
const STRENGTHS = [
2026-10-18 08:37:13 +01:00
[ "light" , "Light" ] ,
[ "medium" , "Medium" ] ,
[ "heavy" , "Heavy" ]
] ;
const MONTHS = [ "January" , "February" , "March" , "April" , "May" , "June" , "July" , "August" , "September" , "October" , "November" , "December" ] ;
const state = {
role : null ,
shareDetails : false ,
symptoms : [ ] ,
days : { } ,
view : "today" ,
month : null ,
2026-10-18 09:43:41 +01:00
sheetDay : null ,
push : null ,
// This device's PushSubscription: undefined until checked, then null or set.
device : undefined
2026-10-18 08:37:13 +01:00
} ;
const $ = id => document . getElementById ( id ) ;
function h ( tag , attributes = { } , ... children ) {
const element = document . createElement ( tag ) ;
for ( const [ name , value ] of Object . entries ( attributes ) ) {
if ( value === false || value === null || value === undefined ) continue ;
if ( name === "class" ) element . className = value ;
else if ( name . startsWith ( "on" ) ) element . addEventListener ( name . slice ( 2 ) , value ) ;
else element . setAttribute ( name , value === true ? "" : value ) ;
}
for ( const child of children . flat ( ) ) {
if ( child === null || child === undefined || child === false ) continue ;
element . append ( child instanceof Node ? child : document . createTextNode ( String ( child ) ) ) ;
}
return element ;
}
const pad = number => String ( number ) . padStart ( 2 , "0" ) ;
const todayIso = ( ) => {
const now = new Date ( ) ;
return ` ${ now . getFullYear ( ) } - ${ pad ( now . getMonth ( ) + 1 ) } - ${ pad ( now . getDate ( ) ) } ` ;
} ;
const formatDay = ( number , options = { day : "numeric" , month : "short" } ) =>
new Date ( number * 86400000 ) . toLocaleDateString ( "en-GB" , { ... options , timeZone : "UTC" } ) ;
const longDay = number => formatDay ( number , { weekday : "long" , day : "numeric" , month : "long" } ) ;
const plural = ( count , word ) => ` ${ count } ${ word } ${ count === 1 ? "" : "s" } ` ;
const range = ( from , to ) => Array . from ( { length : Math . max ( 0 , to - from + 1 ) } , ( _ , index ) => from + index ) ;
// ---- Server --------------------------------------------------------------
async function api ( path , options = { } ) {
// An expired Authelia session answers with a redirect to the login portal,
// which fetch cannot follow cross-origin. Reloading lets the browser do it.
const response = await fetch ( path , { redirect : "manual" , credentials : "same-origin" , ... options } ) ;
if ( response . type === "opaqueredirect" ) {
window . location . reload ( ) ;
throw new Error ( "Signing in again…" ) ;
}
const payload = await response . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! response . ok ) throw new Error ( payload . error || ` Request failed ( ${ response . status } ) ` ) ;
return payload ;
}
const sendJson = ( method , path , body ) =>
api ( path , { method , headers : { "Content-Type" : "application/json" } , body : JSON . stringify ( body ) } ) ;
function showBanner ( message , isError = false ) {
const banner = $ ( "banner" ) ;
banner . textContent = message ;
banner . classList . toggle ( "error" , isError ) ;
banner . hidden = ! message ;
clearTimeout ( showBanner . timer ) ;
if ( message && ! isError ) showBanner . timer = setTimeout ( ( ) => { banner . hidden = true ; } , 4000 ) ;
}
async function load ( ) {
try {
const payload = await api ( "/api/state" ) ;
Object . assign ( state , {
role : payload . role ,
shareDetails : payload . shareDetails ,
symptoms : payload . symptoms ,
2026-10-18 09:43:41 +01:00
days : payload . days ,
push : payload . push
2026-10-18 08:37:13 +01:00
} ) ;
if ( ! $ ( "banner" ) . classList . contains ( "error" ) ) showBanner ( "" ) ;
render ( ) ;
2026-10-18 09:43:41 +01:00
if ( state . device === undefined ) await checkDevice ( ) ;
2026-10-18 08:37:13 +01:00
} catch ( error ) {
showBanner ( error . message === "Not allowed" ? "This account does not have access to Flowtrack." : error . message , true ) ;
}
}
async function saveDay ( iso , entry ) {
const previous = state . days [ iso ] ;
if ( entry . flow === null && ! entry . symptoms . length ) delete state . days [ iso ] ;
else state . days [ iso ] = entry ;
render ( ) ;
try {
await sendJson ( "PUT" , ` /api/days/ ${ iso } ` , entry ) ;
} catch ( error ) {
if ( previous ) state . days [ iso ] = previous ;
else delete state . days [ iso ] ;
render ( ) ;
showBanner ( ` Couldn't save: ${ error . message } ` , true ) ;
}
}
// ---- Shared pieces -------------------------------------------------------
const editable = ( ) => state . role === "editor" ;
const detailsVisible = ( ) => editable ( ) || state . shareDetails ;
function logEditor ( iso ) {
const entry = state . days [ iso ] || { flow : null , symptoms : [ ] } ;
const canEdit = editable ( ) ;
2026-10-18 09:32:29 +01:00
const isPeriod = Cycle . PERIOD _FLOWS . has ( entry . flow ) ;
2026-10-18 08:37:13 +01:00
const update = change => saveDay ( iso , { flow : entry . flow , symptoms : entry . symptoms , ... change } ) ;
2026-10-18 09:32:29 +01:00
const chip = ( label , pressed , onclick ) =>
h ( "button" , { type : "button" , class : "chip" , "aria-pressed" : String ( pressed ) , disabled : ! canEdit , onclick } , label ) ;
const periodChips = [
chip ( "Yes" , isPeriod , ( ) => { if ( ! isPeriod ) update ( { flow : "period" } ) ; } ) ,
chip ( "No" , ! isPeriod , ( ) => { if ( isPeriod ) update ( { flow : null } ) ; } )
] ;
// Strength is optional: tapping the selected one again clears it back to
// a plain period day.
const detail = isPeriod
? h ( "section" , { class : "log-section" } ,
h ( "h3" , { } , "Flow strength" ) ,
h ( "div" , { class : "chips" } , STRENGTHS . map ( ( [ flow , label ] ) =>
chip ( label , entry . flow === flow , ( ) => update ( { flow : entry . flow === flow ? "period" : flow } ) )
) ) )
: h ( "section" , { class : "log-section" } ,
h ( "h3" , { } , "Spotting" ) ,
h ( "div" , { class : "chips" } , chip ( "Spotting" , entry . flow === "spotting" , ( ) => update ( { flow : entry . flow === "spotting" ? null : "spotting" } ) ) ) ) ;
2026-10-18 08:37:13 +01:00
const symptomChips = state . symptoms . map ( ( { id , label } ) => {
const active = entry . symptoms . includes ( id ) ;
return h ( "button" , {
type : "button" ,
class : "chip symptom" ,
"aria-pressed" : String ( active ) ,
disabled : ! canEdit ,
onclick : ( ) => update ( {
symptoms : active ? entry . symptoms . filter ( item => item !== id ) : [ ... entry . symptoms , id ]
} )
} , label ) ;
} ) ;
return h ( "div" , { } ,
2026-10-18 09:32:29 +01:00
h ( "section" , { class : "log-section" } , h ( "h3" , { } , "Period day" ) , h ( "div" , { class : "chips" } , periodChips ) ) ,
detail ,
2026-10-18 08:37:13 +01:00
h ( "section" , { class : "log-section" } , h ( "h3" , { } , "Symptoms" ) , h ( "div" , { class : "chips" } , symptomChips ) )
) ;
}
2026-10-18 09:32:29 +01:00
function flowBars ( level ) {
return h ( "span" , { class : "flow" , "aria-hidden" : "true" } , [ 1 , 2 , 3 ] . map ( step => h ( "i" , { class : step <= level ? "on" : null } ) ) ) ;
}
2026-10-18 09:58:55 +01:00
// Which calendar days carry which marker, from logs and predictions up to
// `until` (the last day on screen; predictions have no horizon).
function dayMarks ( summary , until ) {
2026-10-18 09:32:29 +01:00
const marks = {
period : new Set ( ) , spotting : new Set ( ) , symptoms : new Set ( ) , predicted : new Set ( ) , fertile : new Set ( ) , ovulation : new Set ( ) ,
2026-10-18 09:58:55 +01:00
// Predictions far enough ahead to be shown as less certain.
faded : new Set ( ) ,
// day -> the predicted period it belongs to.
prediction : new Map ( ) ,
2026-10-18 09:32:29 +01:00
// day -> 1..3: the logged strength, or the usual strength for that day
// of the period when predicted.
level : new Map ( )
} ;
2026-10-18 08:37:13 +01:00
for ( const [ iso , entry ] of Object . entries ( state . days ) ) {
const day = Cycle . toDay ( iso ) ;
if ( Cycle . PERIOD _FLOWS . has ( entry . flow ) ) marks . period . add ( day ) ;
2026-10-18 09:32:29 +01:00
if ( Cycle . FLOW _LEVELS [ entry . flow ] ) marks . level . set ( day , Cycle . FLOW _LEVELS [ entry . flow ] ) ;
2026-10-18 08:37:13 +01:00
if ( entry . flow === "spotting" ) marks . spotting . add ( day ) ;
if ( entry . symptoms . length ) marks . symptoms . add ( day ) ;
}
2026-10-18 09:58:55 +01:00
const { current , today , periodLength , flowProfile } = summary ;
const { predictions , fertile } = Cycle . forecast ( summary , until ) ;
const predict = ( from , to , start , prediction ) => {
2026-10-18 09:32:29 +01:00
for ( const day of range ( from , to ) ) {
if ( marks . period . has ( day ) ) continue ;
marks . predicted . add ( day ) ;
2026-10-18 09:58:55 +01:00
if ( prediction ) marks . prediction . set ( day , prediction ) ;
if ( prediction && prediction . faded ) marks . faded . add ( day ) ;
2026-10-18 09:32:29 +01:00
if ( flowProfile [ day - start ] ) marks . level . set ( day , flowProfile [ day - start ] ) ;
}
} ;
2026-10-18 09:58:55 +01:00
if ( current && current . onPeriod ) predict ( today + 1 , current . start + periodLength - 1 , current . start , null ) ;
for ( const prediction of predictions ) predict ( Math . max ( prediction . start , today + 1 ) , prediction . end , prediction . start , prediction ) ;
2026-10-18 08:37:13 +01:00
for ( const window of fertile ) {
2026-10-18 09:58:55 +01:00
for ( const day of range ( window . start , window . end ) ) {
marks . fertile . add ( day ) ;
if ( window . faded ) marks . faded . add ( day ) ;
}
2026-10-18 08:37:13 +01:00
marks . ovulation . add ( window . ovulation ) ;
}
return marks ;
}
function seedForm ( form ) {
const today = todayIso ( ) ;
2026-10-18 09:32:29 +01:00
const periodRow = ( ) => {
const start = h ( "input" , { type : "date" , max : today , class : "seed-start" } ) ;
const end = h ( "input" , { type : "date" , max : today , class : "seed-end" } ) ;
start . addEventListener ( "change" , ( ) => { end . min = start . value ; } ) ;
return h ( "div" , { class : "seed-row" } , h ( "label" , { } , "Started" , start ) , h ( "label" , { } , "Ended" , end ) ) ;
} ;
const rows = h ( "div" , { class : "seed-rows" } , periodRow ( ) , periodRow ( ) ) ;
2026-10-18 08:37:13 +01:00
const submit = h ( "button" , { type : "submit" , class : "button" } , "Save periods" ) ;
form . replaceChildren (
2026-10-18 09:32:29 +01:00
rows ,
h ( "button" , { type : "button" , class : "link-button" , onclick : ( ) => rows . append ( periodRow ( ) ) } , "+ Add another period" ) ,
h ( "p" , { class : "seed-hint" } ) ,
2026-10-18 08:37:13 +01:00
submit
) ;
form . onsubmit = async event => {
event . preventDefault ( ) ;
2026-10-18 09:32:29 +01:00
// A missing end uses the average length of what is already logged.
const usual = Cycle . summarize ( state . days , today ) . periodLength ;
2026-10-18 08:37:13 +01:00
const todayNumber = Cycle . toDay ( today ) ;
const entries = { } ;
2026-10-18 09:32:29 +01:00
for ( const row of rows . children ) {
const start = row . querySelector ( ".seed-start" ) . value ;
const end = row . querySelector ( ".seed-end" ) . value ;
if ( ! start ) continue ;
const first = Cycle . toDay ( start ) ;
const last = end ? Cycle . toDay ( end ) : first + usual - 1 ;
if ( last < first || last - first > 14 ) {
showBanner ( ` The period starting ${ formatDay ( first ) } needs an end date within two weeks of its start. ` , true ) ;
return ;
}
for ( const day of range ( first , Math . min ( last , todayNumber ) ) ) {
2026-10-18 08:37:13 +01:00
entries [ Cycle . toIso ( day ) ] = { flow : "period" , symptoms : [ ] } ;
}
}
if ( ! Object . keys ( entries ) . length ) {
showBanner ( "Pick at least one start date." , true ) ;
return ;
}
submit . disabled = true ;
try {
const { added } = await sendJson ( "POST" , "/api/days" , { days : entries } ) ;
showBanner ( added ? ` Added ${ plural ( added , "day" ) } . ` : "Those days were already logged." ) ;
2026-10-18 09:32:29 +01:00
for ( const input of rows . querySelectorAll ( "input" ) ) input . value = "" ;
2026-10-18 08:37:13 +01:00
await load ( ) ;
} catch ( error ) {
showBanner ( ` Couldn't save: ${ error . message } ` , true ) ;
} finally {
submit . disabled = false ;
}
} ;
return form ;
}
// ---- Today ---------------------------------------------------------------
function renderHero ( summary ) {
const hero = $ ( "hero" ) ;
hero . className = "hero card" ;
const { current , predictions , late , today , confidence , sampleSize } = summary ;
if ( ! current ) {
hero . replaceChildren ( ... [
h ( "p" , { class : "eyebrow" } , "Welcome" ) ,
2026-10-18 09:32:29 +01:00
h ( "p" , { class : "lead" } , editable ( ) ? "When were your recent periods?" : "Nothing has been logged yet." ) ,
editable ( ) && h ( "p" , { class : "detail" } , "Two or three periods are enough to start predicting. You can add more later in Settings." ) ,
// Kept across renders so a refresh does not wipe half-entered dates.
editable ( ) && ( renderHero . seed || = seedForm ( h ( "form" , { class : "seed-form facts" } ) ) )
2026-10-18 08:37:13 +01:00
] . filter ( Boolean ) ) ;
return ;
}
const next = predictions [ 0 ] ;
const untilNext = next . start - today ;
let headline ;
if ( current . onPeriod ) {
hero . classList . add ( "on-period" ) ;
headline = [
h ( "p" , { class : "eyebrow" } , "Period" ) ,
h ( "p" , { class : "figure" } , "Day " , today - current . start + 1 ) ,
2026-10-18 09:32:29 +01:00
h ( "p" , { class : "lead" } , ` Usually lasts about ${ plural ( summary . periodLength , "day" ) } ` ) ,
usualFlow ( summary , today - current . start ) && h ( "p" , { class : "detail" } , ` Day ${ today - current . start + 1 } is usually ${ usualFlow ( summary , today - current . start ) } ` )
] . filter ( Boolean ) ;
2026-10-18 08:37:13 +01:00
} else if ( late ) {
headline = [
h ( "p" , { class : "eyebrow" } , "Period expected" ) ,
h ( "p" , { class : "figure" } , late , h ( "small" , { } , late === 1 ? " day late" : " days late" ) ) ,
h ( "p" , { class : "lead" } , ` It was expected on ${ formatDay ( today - late , { weekday : "short" , day : "numeric" , month : "short" } )} ` ) ,
h ( "p" , { class : "detail" } , "A few days either way is common. Log it here when it starts." )
] ;
} else {
headline = [
h ( "p" , { class : "eyebrow" } , "Cycle day" ) ,
h ( "p" , { class : "figure" } , current . cycleDay ) ,
h ( "p" , { class : "lead" } , untilNext === 0 ? "Period expected today" : ` Next period in about ${ plural ( untilNext , "day" ) } ` ) ,
h ( "p" , { class : "detail" } , ` Around ${ formatDay ( next . start , { weekday : "short" , day : "numeric" , month : "short" } )} · likely ${ formatDay ( next . earliest ) } – ${ formatDay ( next . latest ) } ` )
] ;
}
const facts = [ ] ;
if ( current . onPeriod || late ) {
const upcoming = late ? predictions [ 1 ] : next ;
facts . push ( fact ( "key-predicted" , "Next predicted period" , ` ${ formatDay ( upcoming . start ) } · likely ${ formatDay ( upcoming . earliest ) } – ${ formatDay ( upcoming . latest ) } ` ) ) ;
}
const window = summary . fertile . find ( item => item . end >= today ) ;
if ( window ) {
const inWindow = window . start <= today ;
facts . push ( fact (
"key-ovulation" ,
inWindow ? "Fertile window now (estimate)" : "Next fertile window (estimate)" ,
inWindow
? ` Until ${ formatDay ( window . end ) } · ovulation around ${ formatDay ( window . ovulation ) } `
: ` ${ formatDay ( window . start ) } – ${ formatDay ( window . end ) } · ovulation around ${ formatDay ( window . ovulation ) } `
) ) ;
}
const note = confidence === "none"
? "Log another period to personalise predictions. Until then they assume a 28-day cycle."
: confidence === "low"
? ` Based on ${ plural ( sampleSize , "cycle" ) } so far. Predictions get sharper after three. `
: null ;
hero . replaceChildren ( ... [ ... headline , facts . length && h ( "div" , { class : "facts" } , facts ) , note && h ( "p" , { class : "note" } , note ) ] . filter ( Boolean ) ) ;
}
2026-10-18 09:32:29 +01:00
const usualFlow = ( summary , index ) => Cycle . LEVEL _NAMES [ summary . flowProfile [ index ] ] || null ;
2026-10-18 08:37:13 +01:00
function fact ( keyClass , title , text ) {
return h ( "div" , { class : "fact" } , h ( "span" , { class : ` key ${ keyClass } ` } ) , h ( "div" , { } , h ( "strong" , { } , title ) , h ( "span" , { } , text ) ) ) ;
}
function renderTodayLog ( ) {
const card = $ ( "today-log" ) ;
card . hidden = ! detailsVisible ( ) ;
if ( card . hidden ) return ;
card . replaceChildren ( h ( "h2" , { } , editable ( ) ? "How's today?" : "Today" ) , h ( "p" , { class : "subtitle" } , longDay ( Cycle . toDay ( todayIso ( ) ) ) ) , logEditor ( todayIso ( ) ) ) ;
}
// ---- Calendar ------------------------------------------------------------
function renderCalendar ( summary ) {
const { year , month } = state . month ;
$ ( "month-title" ) . textContent = ` ${ MONTHS [ month ] } ${ year } ` ;
const first = Cycle . toDay ( ` ${ year } - ${ pad ( month + 1 ) } -01 ` ) ;
const count = new Date ( Date . UTC ( year , month + 1 , 0 ) ) . getUTCDate ( ) ;
2026-10-18 09:58:55 +01:00
const marks = dayMarks ( summary , first + count - 1 ) ;
2026-10-18 08:37:13 +01:00
const leading = ( new Date ( first * 86400000 ) . getUTCDay ( ) + 6 ) % 7 ;
const cells = range ( 1 , leading ) . map ( ( ) => h ( "span" , { class : "day outside" , "aria-hidden" : "true" } ) ) ;
for ( const day of range ( first , first + count - 1 ) ) {
const kinds = [ ] ;
if ( marks . period . has ( day ) ) kinds . push ( "period" ) ;
else if ( marks . predicted . has ( day ) ) kinds . push ( "predicted" ) ;
if ( marks . fertile . has ( day ) && ! marks . period . has ( day ) ) kinds . push ( "fertile" ) ;
if ( day === summary . today ) kinds . push ( "today" ) ;
if ( day > summary . today ) kinds . push ( "future" ) ;
2026-10-18 09:58:55 +01:00
if ( marks . faded . has ( day ) ) kinds . push ( "faded" ) ;
2026-10-18 08:37:13 +01:00
const dots = [ ] ;
if ( marks . ovulation . has ( day ) ) dots . push ( "ovulation" ) ;
if ( marks . spotting . has ( day ) ) dots . push ( "spotting" ) ;
if ( marks . symptoms . has ( day ) ) dots . push ( "symptoms" ) ;
2026-10-18 09:32:29 +01:00
const level = marks . level . get ( day ) ;
2026-10-18 08:37:13 +01:00
const described = [
marks . period . has ( day ) && "period" ,
2026-10-18 09:32:29 +01:00
marks . predicted . has ( day ) && "predicted period" ,
level && ` ${ marks . predicted . has ( day ) ? "usually " : "" } ${ Cycle . LEVEL _NAMES [ level ] } flow ` ,
2026-10-18 08:37:13 +01:00
marks . fertile . has ( day ) && "fertile window estimate" ,
marks . ovulation . has ( day ) && "estimated ovulation" ,
marks . spotting . has ( day ) && "spotting" ,
2026-10-18 09:58:55 +01:00
marks . symptoms . has ( day ) && "symptoms logged" ,
marks . faded . has ( day ) && "less certain this far ahead"
2026-10-18 08:37:13 +01:00
] . filter ( Boolean ) ;
cells . push ( h ( "button" , {
type : "button" ,
class : [ "day" , ... kinds ] . join ( " " ) ,
"aria-label" : [ longDay ( day ) , ... described ] . join ( ", " ) ,
"aria-current" : day === summary . today ? "date" : null ,
onclick : ( ) => openSheet ( day )
2026-10-18 09:32:29 +01:00
} , formatDay ( day , { day : "numeric" } ) , level || dots . length
? h ( "span" , { class : "marks" } , level && flowBars ( level ) , dots . map ( kind => h ( "span" , { class : ` mark ${ kind } ` } ) ) )
: null ) ) ;
2026-10-18 08:37:13 +01:00
}
$ ( "month-grid" ) . replaceChildren ( ... cells ) ;
}
function shiftMonth ( delta ) {
const { year , month } = state . month ;
const next = new Date ( Date . UTC ( year , month + delta , 1 ) ) ;
state . month = { year : next . getUTCFullYear ( ) , month : next . getUTCMonth ( ) } ;
render ( ) ;
}
function openSheet ( day ) {
state . sheetDay = day ;
renderSheet ( Cycle . summarize ( state . days , todayIso ( ) ) ) ;
const sheet = $ ( "day-sheet" ) ;
if ( ! sheet . open ) sheet . showModal ( ) ;
}
function renderSheet ( summary ) {
const day = state . sheetDay ;
if ( day === null ) return ;
2026-10-18 09:58:55 +01:00
const marks = dayMarks ( summary , day ) ;
2026-10-18 08:37:13 +01:00
$ ( "sheet-title" ) . textContent = longDay ( day ) ;
const notes = [ ] ;
2026-10-18 09:32:29 +01:00
if ( marks . predicted . has ( day ) ) {
const level = marks . level . get ( day ) ;
notes . push ( level ? ` Predicted period day, usually ${ Cycle . LEVEL _NAMES [ level ] } flow. ` : "Predicted period day." ) ;
2026-10-18 09:58:55 +01:00
const prediction = marks . prediction . get ( day ) ;
if ( prediction ) notes . push ( ` This period is expected to start around ${ formatDay ( prediction . start ) } , likely between ${ formatDay ( prediction . earliest ) } and ${ formatDay ( prediction . latest ) } . ` ) ;
2026-10-18 09:32:29 +01:00
}
2026-10-18 08:37:13 +01:00
if ( marks . ovulation . has ( day ) ) notes . push ( "Estimated ovulation day." ) ;
else if ( marks . fertile . has ( day ) ) notes . push ( "Inside the estimated fertile window." ) ;
2026-10-18 09:58:55 +01:00
if ( marks . faded . has ( day ) ) notes . push ( "This far ahead the prediction is less certain." ) ;
2026-10-18 08:37:13 +01:00
const body = [ ] ;
if ( notes . length ) body . push ( h ( "p" , { class : "prediction-note" } , notes . join ( " " ) ) ) ;
if ( day > summary . today ) {
if ( ! notes . length ) body . push ( h ( "p" , { class : "prediction-note" } , "Nothing predicted for this day." ) ) ;
} else if ( detailsVisible ( ) ) {
body . push ( logEditor ( Cycle . toIso ( day ) ) ) ;
} else {
body . push ( h ( "p" , { class : "prediction-note" } , marks . period . has ( day ) ? "Period day." : "Nothing logged." ) ) ;
}
$ ( "sheet-body" ) . replaceChildren ( ... body ) ;
}
// ---- Stats ---------------------------------------------------------------
function tile ( label , value , unit ) {
return h ( "div" , { class : "tile" } , h ( "p" , { class : "label" } , label ) , h ( "p" , { class : "value" } , value , unit && h ( "small" , { } , ` ${ unit } ` ) ) ) ;
}
function renderStats ( summary ) {
const { sampleSize , cycleLength , cycleSpread , periodLength , cycles , current , today } = summary ;
$ ( "tiles" ) . replaceChildren (
tile ( "Average cycle" , sampleSize ? cycleLength : "– " , sampleSize ? "days" : "" ) ,
tile ( "Cycle variation" , sampleSize > 1 ? ` ± ${ Math . max ( 1 , Math . round ( cycleSpread ) ) } ` : "– " , sampleSize > 1 ? "days" : "" ) ,
tile ( "Average period" , current ? periodLength : "– " , current ? "days" : "" ) ,
tile ( "Cycles tracked" , sampleSize )
) ;
renderChart ( cycles . filter ( cycle => cycle . used ) . slice ( - 12 ) , summary ) ;
2026-10-18 09:58:55 +01:00
renderUpcoming ( summary ) ;
2026-10-18 08:37:13 +01:00
const rows = [ ] ;
if ( current ) {
rows . push ( h ( "tr" , { } ,
h ( "td" , { } , formatDay ( current . start , { day : "numeric" , month : "short" , year : "numeric" } ) ) ,
h ( "td" , { } , ` Day ${ current . cycleDay } so far ` ) ,
h ( "td" , { } , current . end >= today - 1 ? "Ongoing" : plural ( current . end - current . start + 1 , "day" ) )
) ) ;
}
for ( const cycle of cycles . slice ( ) . reverse ( ) ) {
rows . push ( h ( "tr" , { class : cycle . used ? null : "excluded" } ,
h ( "td" , { } , formatDay ( cycle . start , { day : "numeric" , month : "short" , year : "numeric" } ) ) ,
h ( "td" , { } , plural ( cycle . length , "day" ) , ! cycle . used && h ( "small" , { } , "Not used: likely a missed log" ) ) ,
h ( "td" , { } , plural ( cycle . periodLength , "day" ) )
) ) ;
}
if ( ! rows . length ) rows . push ( h ( "tr" , { } , h ( "td" , { colspan : "3" } , "Nothing logged yet." ) ) ) ;
$ ( "history" ) . replaceChildren ( ... rows ) ;
}
2026-10-18 09:58:55 +01:00
// A year of predictions as a list, for planning travel and the like.
function renderUpcoming ( summary ) {
const { predictions } = Cycle . forecast ( summary , summary . today + 365 , 1 ) ;
const thisYear = new Date ( summary . today * 86400000 ) . getUTCFullYear ( ) ;
// Built by hand: en-GB adds a comma after the weekday once a year is included.
const dated = number => {
const year = new Date ( number * 86400000 ) . getUTCFullYear ( ) ;
const day = formatDay ( number , { weekday : "short" , day : "numeric" , month : "short" } ) ;
return year === thisYear ? day : ` ${ day } ${ year } ` ;
} ;
const rows = predictions . map ( prediction => h ( "li" , { class : prediction . faded ? "faded" : null } ,
h ( "span" , { class : "when" } , dated ( prediction . start ) ) ,
h ( "span" , { class : "likely" } , ` likely ${ formatDay ( prediction . earliest ) } – ${ formatDay ( prediction . latest ) } ` , prediction . faded && h ( "small" , { } , "less certain" ) )
) ) ;
$ ( "upcoming" ) . replaceChildren ( ... ( rows . length ? rows : [ h ( "li" , { } , "Log a period to see predictions." ) ] ) ) ;
}
2026-10-18 08:37:13 +01:00
function renderChart ( data , summary ) {
const container = $ ( "chart" ) ;
if ( data . length < 2 ) {
container . replaceChildren ( h ( "p" , { class : "chart-empty" } , "The chart appears once two full cycles are logged." ) ) ;
return ;
}
const svgNs = "http://www.w3.org/2000/svg" ;
const s = ( tag , attributes = { } ) => {
const element = document . createElementNS ( svgNs , tag ) ;
for ( const [ name , value ] of Object . entries ( attributes ) ) element . setAttribute ( name , value ) ;
return element ;
} ;
const width = 320 ;
const height = 170 ;
const left = 26 ;
const top = 16 ;
const bottom = 22 ;
const plotHeight = height - top - bottom ;
const ceiling = Math . max ( 35 , Math . ceil ( Math . max ( ... data . map ( cycle => cycle . length ) ) / 10 ) * 10 ) ;
const y = value => top + plotHeight - ( value / ceiling ) * plotHeight ;
const band = ( width - left ) / data . length ;
const barWidth = Math . min ( 24 , band - 4 ) ;
const svg = s ( "svg" , { viewBox : ` 0 0 ${ width } ${ height } ` , role : "img" , "aria-label" : ` Cycle lengths for the last ${ data . length } cycles; the history table below lists every value. ` } ) ;
for ( let tick = 0 ; tick <= ceiling ; tick += 10 ) {
svg . append ( s ( "line" , { class : "grid" , x1 : left , x2 : width , y1 : y ( tick ) , y2 : y ( tick ) } ) ) ;
const label = s ( "text" , { class : "axis-label" , x : left - 6 , y : y ( tick ) + 4 , "text-anchor" : "end" } ) ;
label . textContent = tick ;
svg . append ( label ) ;
}
const tooltip = h ( "div" , { class : "tooltip" , hidden : true } ) ;
const show = ( index , x ) => {
const cycle = data [ index ] ;
tooltip . textContent = ` ${ formatDay ( cycle . start ) } · ${ plural ( cycle . length , "day" ) } ` ;
tooltip . style . left = ` ${ ( x / width ) * 100 } % ` ;
tooltip . style . top = ` ${ ( y ( cycle . length ) / height ) * 100 } % ` ;
tooltip . hidden = false ;
} ;
data . forEach ( ( cycle , index ) => {
const x = left + band * index + ( band - barWidth ) / 2 ;
const yTop = y ( cycle . length ) ;
const base = y ( 0 ) ;
const radius = Math . min ( 4 , base - yTop ) ;
// Rounded at the data end, square at the baseline.
svg . append ( s ( "path" , {
class : "bar" ,
d : ` M ${ x } , ${ base } V ${ yTop + radius } Q ${ x } , ${ yTop } ${ x + radius } , ${ yTop } H ${ x + barWidth - radius } Q ${ x + barWidth } , ${ yTop } ${ x + barWidth } , ${ yTop + radius } V ${ base } Z `
} ) ) ;
const hit = s ( "rect" , { class : "hit" , x : left + band * index , y : top , width : band , height : plotHeight } ) ;
hit . addEventListener ( "pointerenter" , ( ) => show ( index , x + barWidth / 2 ) ) ;
hit . addEventListener ( "click" , ( ) => show ( index , x + barWidth / 2 ) ) ;
hit . addEventListener ( "pointerleave" , ( ) => { tooltip . hidden = true ; } ) ;
svg . append ( hit ) ;
} ) ;
const average = s ( "line" , { class : "avg" , x1 : left , x2 : width , y1 : y ( summary . cycleLength ) , y2 : y ( summary . cycleLength ) } ) ;
const averageLabel = s ( "text" , { class : "avg-label" , x : width , y : y ( summary . cycleLength ) - 5 , "text-anchor" : "end" } ) ;
averageLabel . textContent = ` avg ${ summary . cycleLength } ` ;
svg . insertBefore ( average , svg . querySelector ( ".hit" ) ) ;
svg . insertBefore ( averageLabel , svg . querySelector ( ".hit" ) ) ;
for ( const index of [ 0 , data . length - 1 ] ) {
const label = s ( "text" , { class : "axis-label" , x : left + band * index + band / 2 , y : height - 6 , "text-anchor" : "middle" } ) ;
label . textContent = formatDay ( data [ index ] . start , { month : "short" , year : "numeric" } ) ;
svg . append ( label ) ;
}
container . replaceChildren ( svg , tooltip ) ;
}
// ---- Settings ------------------------------------------------------------
2026-10-18 09:43:41 +01:00
// ---- Notifications -------------------------------------------------------
const isIos = ( ) => / iPhone | iPad | iPod / . test ( navigator . userAgent ) || ( navigator . platform === "MacIntel" && navigator . maxTouchPoints > 1 ) ;
const installed = ( ) => window . navigator . standalone === true || window . matchMedia ( "(display-mode: standalone)" ) . matches ;
const pushSupported = ( ) => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window ;
const keyBytes = key => Uint8Array . from ( atob ( key . replace ( /-/g , "+" ) . replace ( /_/g , "/" ) ) , char => char . charCodeAt ( 0 ) ) ;
const keyText = buffer => btoa ( String . fromCharCode ( ... new Uint8Array ( buffer ) ) ) . replace ( /\+/g , "-" ) . replace ( /\//g , "_" ) . replace ( /=+$/ , "" ) ;
// serviceWorker.ready never settles if registration failed, so give up.
const workerReady = ( ) => Promise . race ( [
navigator . serviceWorker . ready ,
new Promise ( ( _ , reject ) => setTimeout ( ( ) => reject ( new Error ( "the notification worker did not start" ) ) , 8000 ) )
] ) ;
async function checkDevice ( ) {
if ( ! editable ( ) || ! state . push || ! state . push . available || ! pushSupported ( ) ) {
state . device = null ;
return ;
}
try {
const registration = await workerReady ( ) ;
let subscription = await registration . pushManager . getSubscription ( ) ;
// A subscription made for a different server key can never be
// delivered to, so drop it rather than show notifications as on.
if ( subscription && subscription . options . applicationServerKey &&
keyText ( subscription . options . applicationServerKey ) !== state . push . publicKey ) {
await subscription . unsubscribe ( ) ;
subscription = null ;
}
// Re-register on every launch so the server copy survives a restore.
if ( subscription ) await sendJson ( "POST" , "/api/push/subscribe" , subscription . toJSON ( ) ) ;
state . device = subscription ;
} catch {
state . device = null ;
}
render ( ) ;
}
async function enablePush ( ) {
try {
// iOS only shows the permission prompt straight from a tap, so this
// must be the first thing awaited in the click handler.
const permission = await Notification . requestPermission ( ) ;
if ( permission !== "granted" ) {
showBanner ( "Notifications were not allowed." , true ) ;
render ( ) ;
return ;
}
const registration = await workerReady ( ) ;
const subscription = await registration . pushManager . subscribe ( {
userVisibleOnly : true ,
applicationServerKey : keyBytes ( state . push . publicKey )
} ) ;
const { devices } = await sendJson ( "POST" , "/api/push/subscribe" , subscription . toJSON ( ) ) ;
state . device = subscription ;
state . push . devices = devices ;
showBanner ( "Notifications are on. Send a test to check." ) ;
} catch ( error ) {
showBanner ( ` Couldn't turn on notifications: ${ error . message } ` , true ) ;
}
render ( ) ;
}
async function disablePush ( ) {
try {
const { devices } = await sendJson ( "POST" , "/api/push/unsubscribe" , { endpoint : state . device . endpoint } ) ;
await state . device . unsubscribe ( ) ;
state . device = null ;
state . push . devices = devices ;
showBanner ( "Notifications are off on this device." ) ;
} catch ( error ) {
showBanner ( ` Couldn't turn off notifications: ${ error . message } ` , true ) ;
}
render ( ) ;
}
async function testPush ( button ) {
button . disabled = true ;
try {
const result = await api ( "/api/push/test" , { method : "POST" } ) ;
if ( result . sent ) showBanner ( ` Test sent to ${ plural ( result . sent , "device" ) } . It should arrive within a few seconds. ` ) ;
else showBanner ( result . errors [ 0 ] ? ` Test failed: ${ result . errors [ 0 ] } ` : "No devices are signed up for notifications." , true ) ;
} catch ( error ) {
showBanner ( ` Test failed: ${ error . message } ` , true ) ;
} finally {
button . disabled = false ;
}
}
async function setRemindDays ( remindDays ) {
try {
await sendJson ( "PUT" , "/api/settings" , { remindDays } ) ;
state . push . remindDays = remindDays ;
showBanner ( ` You'll be reminded ${ plural ( remindDays , "day" ) } before. ` ) ;
render ( ) ;
} catch ( error ) {
showBanner ( ` Couldn't save: ${ error . message } ` , true ) ;
}
}
function renderNotifications ( ) {
const body = $ ( "notify-body" ) ;
const note = text => h ( "p" , { class : "notify-note" } , text ) ;
if ( ! state . push || ! state . push . available ) {
body . replaceChildren ( note ( "Notifications are not set up on the server yet." ) ) ;
return ;
}
if ( ! pushSupported ( ) ) {
body . replaceChildren ( note ( isIos ( ) && ! installed ( )
? "On iPhone, notifications only work from the Home Screen app. In Safari tap Share, then Add to Home Screen, and open Flowtrack from there."
: "This browser does not support notifications." ) ) ;
return ;
}
if ( Notification . permission === "denied" ) {
body . replaceChildren ( note ( isIos ( )
? "Notifications are blocked. Turn them on in the iPhone Settings app under Notifications, Flowtrack."
: "Notifications are blocked for this site in the browser settings." ) ) ;
return ;
}
if ( state . device === undefined ) {
body . replaceChildren ( note ( "Checking…" ) ) ;
return ;
}
const days = h ( "select" , { "aria-label" : "Days before" } , range ( 1 , 7 ) . map ( count =>
h ( "option" , { value : String ( count ) , selected : count === state . push . remindDays } , String ( count ) )
) ) ;
days . addEventListener ( "change" , ( ) => setRemindDays ( Number ( days . value ) ) ) ;
const remind = h ( "label" , { class : "remind-row" } , "Remind me" , days , state . push . remindDays === 1 ? "day before" : "days before" ) ;
if ( ! state . device ) {
body . replaceChildren (
h ( "button" , { type : "button" , class : "button" , onclick : enablePush } , "Turn on notifications" ) ,
remind
) ;
return ;
}
const test = h ( "button" , { type : "button" , class : "button" } , "Send test notification" ) ;
test . addEventListener ( "click" , ( ) => testPush ( test ) ) ;
body . replaceChildren (
h ( "p" , { class : "notify-status" } , h ( "span" , { class : "status-dot" } ) , "On for this device" ) ,
remind ,
h ( "div" , { class : "button-row" } , test , h ( "button" , { type : "button" , class : "button secondary" , onclick : disablePush } , "Turn off" ) )
) ;
}
2026-10-18 09:32:29 +01:00
function renderSettings ( summary ) {
2026-10-18 09:43:41 +01:00
renderNotifications ( ) ;
2026-10-18 08:37:13 +01:00
$ ( "share-toggle" ) . checked = state . shareDetails ;
2026-10-18 09:32:29 +01:00
const hint = summary . periodSamples
? ` Leave "Ended" empty to use the average length so far, ${ plural ( summary . periodLength , "day" ) } . `
: ` Leave "Ended" empty to assume ${ plural ( summary . periodLength , "day" ) } until there is more to average. ` ;
for ( const element of document . querySelectorAll ( ".seed-hint" ) ) element . textContent = hint ;
2026-10-18 08:37:13 +01:00
}
// ---- Shell ---------------------------------------------------------------
function setView ( view ) {
state . view = view ;
for ( const button of document . querySelectorAll ( ".tabs button" ) ) {
button . setAttribute ( "aria-selected" , String ( button . dataset . view === view ) ) ;
}
for ( const section of document . querySelectorAll ( ".view" ) ) section . hidden = section . id !== ` view- ${ view } ` ;
window . scrollTo ( 0 , 0 ) ;
}
function render ( ) {
if ( ! state . role ) return ;
const summary = Cycle . summarize ( state . days , todayIso ( ) ) ;
const viewer = ! editable ( ) ;
$ ( "role-badge" ) . hidden = ! viewer ;
$ ( "tab-settings" ) . hidden = viewer ;
document . querySelector ( ".tabs" ) . classList . toggle ( "three" , viewer ) ;
if ( viewer && state . view === "settings" ) setView ( "today" ) ;
renderHero ( summary ) ;
renderTodayLog ( ) ;
renderCalendar ( summary ) ;
renderStats ( summary ) ;
2026-10-18 09:32:29 +01:00
renderSettings ( summary ) ;
2026-10-18 08:37:13 +01:00
renderSheet ( summary ) ;
}
function resetMonth ( ) {
const now = new Date ( ) ;
state . month = { year : now . getFullYear ( ) , month : now . getMonth ( ) } ;
}
resetMonth ( ) ;
for ( const button of document . querySelectorAll ( ".tabs button" ) ) {
button . addEventListener ( "click" , ( ) => setView ( button . dataset . view ) ) ;
}
const linked = window . location . hash . slice ( 1 ) ;
if ( [ "calendar" , "stats" , "settings" ] . includes ( linked ) ) setView ( linked ) ;
$ ( "month-prev" ) . addEventListener ( "click" , ( ) => shiftMonth ( - 1 ) ) ;
$ ( "month-next" ) . addEventListener ( "click" , ( ) => shiftMonth ( 1 ) ) ;
$ ( "month-today" ) . addEventListener ( "click" , ( ) => { resetMonth ( ) ; render ( ) ; } ) ;
$ ( "day-sheet" ) . addEventListener ( "close" , ( ) => { state . sheetDay = null ; } ) ;
$ ( "day-sheet" ) . addEventListener ( "click" , event => {
if ( event . target === event . currentTarget ) event . currentTarget . close ( ) ;
} ) ;
$ ( "share-toggle" ) . addEventListener ( "change" , async event => {
const shareDetails = event . target . checked ;
try {
await sendJson ( "PUT" , "/api/settings" , { shareDetails } ) ;
state . shareDetails = shareDetails ;
showBanner ( shareDetails ? "Flow and symptoms are now shared." : "Flow and symptoms are private again." ) ;
} catch ( error ) {
event . target . checked = ! shareDetails ;
showBanner ( ` Couldn't save: ${ error . message } ` , true ) ;
}
} ) ;
seedForm ( $ ( "seed-form" ) ) ;
2026-10-18 09:43:41 +01:00
if ( "serviceWorker" in navigator ) navigator . serviceWorker . register ( "/sw.js" ) . catch ( ( ) => { } ) ;
2026-10-18 08:37:13 +01:00
// The home-screen app is often resumed rather than reopened, so refresh
// whenever it comes back to the foreground (this also rolls "today" over).
document . addEventListener ( "visibilitychange" , ( ) => {
if ( document . visibilityState === "visible" ) load ( ) ;
} ) ;
load ( ) ;
} ) ( ) ;