Files
SolidScribe/client/src/pages/MetrictrackingPage.vue
T

1747 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<style scoped>
div.no-padding {
padding: 10px 0 40px !important;
box-sizing: border-box;
}
div.calendar {
width: calc(100% - 20px);
min-height: 350px;
display: flex;
margin: 5px 10px 15px;
flex-wrap: wrap;
flex-direction: row;
justify-content: flex-start;
}
.day {
/*width: calc(100% / 8);*/
flex: 0 0 calc(14.28% - 2px);
height: 50px;
border: 1px solid var(--border_color);
font-size: 1.2em;
cursor: pointer;
/*overflow: hidden;*/
box-sizing: border-box;
margin: 1px;
padding: 0 0 0 5px;
position: relative;
}
.today {
font-weight: bold;
text-decoration: underline;
}
.active-entry {
outline: #07f4f4;
outline-style: none;
outline-width: medium;
outline-style: none;
outline-offset: -1px;
outline-style: solid;
outline-width: 3px;
}
.day ~ .has-data {
}
.day ~ .no-data {
background: #c7c7c787;
opacity: 0.6;
}
.day > .number {
position: absolute;
top: 0;
right: 5px;
z-index: 10;
}
.day > .sex {
font-size: 0.7em;
border-radius: 5px;
background: rgba(249, 0, 0, 0.15);
color: white;
padding: 0 0 0 4px;
z-index: 10;
position: absolute;
left: 0;
height: 26px;
}
.day > .period {
position: absolute;
bottom: 1px;
left: 1px;
right: 1px;
height: 5px;
background: red;
z-index: 10;
}
.day > .mucus {
position: absolute;
bottom: 0;
left: 0;
right: 0;
min-height: 10px;
background: #abecff7d;
z-index: 2;
}
.day > .notes {
}
.key-holder {
display: flex;
width: 100%;
flex-wrap: wrap;
flex-direction: row;
justify-content: flex-start;
}
.key-display {
/*width: calc(100% - 70px);*/
float: left;
height: 50px;
box-sizing: border-box;
padding: 10px 0 0 10px;
/*flex-basis: 70%;*/
flex: 1 0 80%;
}
.option-buttons {
display: flex;
width: 100%;
flex-wrap: wrap;
flex-direction: column;
}
.option-buttons > div.button {
flex: 1;
margin: 0 0 3px 0;
text-align: left;
}
.section-navigation {
display: none;
position: fixed;
top: 10px;
z-index: 30;
opacity: 0.7;
}
.section-navigation.left {
left: -3px;
}
.section-navigation.right {
right: -5px;
}
@media (max-width:620px) {
div.no-padding {
scroll-snap-type: x mandatory;
display: flex;
overflow-x: scroll;
overflow-y: hidden;
width: 100vw;
height: 100vh;
display:flex;
flex-direction:row;
justify-content: space-between;
}
.swipe-section {
height: calc(100vh - 60px);
width: 100vw;
scroll-snap-align: start end;
scroll-snap-stop: always;
overflow-x: hidden;
overflow-y: scroll;
flex:0 0 auto;
}
div.section-navigation {
display: inline-block;
}
}
/* drag stuff*/
.ghost {
opacity: 0.5;
background: #c8ebfb;
}
.draggable {
cursor: move;
}
.past-entries {
width: 100%;
display: flex;
justify-content: space-around;
/* padding: 0 10px;*/
overflow-x: scroll;
overflow-y: hidden;
}
.past-entry {
position: relative;
text-align: center;
border: 1px solid;
border-color: var(--dark_border_color);
color: var(--text_color);
flex-grow: 1;
cursor: pointer;
font-weight: bold;
min-width: 40px;
min-height: 40px;
margin: 5px 0 10px;
line-height: 2.3em;
}
.past-entry > span {
position: absolute;
top: 0;
left: 0;
right: 0;
opacity: 0.4;
font-size: 1.3em;
}
/* .has*/
.loading-day {
opacity: 1;
animation-duration: 0.5s;
animation-name: slidein;
transition-timing-function: ease-in;
}
@keyframes slidein {
0% {
opacity: 0;
transform: translateY(20%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.loading-day-title {
opacity: 1;
display: inline-block;
animation-duration: 0.5s;
animation-name: textupdate;
transition-timing-function: ease-in;
}
@keyframes textupdate {
0% {
color: var(--main-accent);
transform: scale(1.2);
}
100% {
color: var(--text_color);
transform: scale(1);
}
}
.days-ago-display {
font-size: 0.7em;
}
.input-grid {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
}
.bottom.aligned.row {
flex: 1 1 auto;
}
</style>
<template>
<div class="squire-box no-padding" ref="scrollcontainer">
<!-- intro modal -->
<modal v-if="!noteId" :click-out-close="false">
<div class="ui segment">
<div class="ui center aligned middle aligned grid">
<div class="sixteen wide column">
<p>Start Metric Tracking?</p>
<p>&nbsp;</p>
<div class="ui green button" v-on:click="setupMetricTracking">
YES
</div>
<router-link class="ui orange button" to="/notes">
No!!
</router-link>
</div>
</div>
</div>
</modal>
<div class="section-navigation right">
<div class="ui compact basic icon button" v-on:click="scrollApp(100)">
<i class="right arrow icon"></i>
</div>
</div>
<div class="section-navigation left">
<div class="ui compact basic icon button" v-on:click="scrollApp(-100)">
<i class="left arrow icon"></i>
</div>
</div>
<!-- section 1 entry -->
<section class="swipe-section" v-if="true">
<!-- <delete-button v-if="noteId > 0" class="ui small button" :note-id="noteId" /> -->
<div class="ui small centered dividing header">
Metric Tracking
<span class="sub header"><i class="small lock icon"></i>All data Encrypted. Only accessible by you.</span>
</div>
<!-- day navigation -->
<div class="ui compact grid">
<div class="past-entries" ref="pastEntries">
<div v-for="dateCode in getPastThirtyDayCodes" v-on:click="openDayData(dateCode)"
class="past-entry" :class="{'active-entry':(dateCode == calendar.dateCode)}">
<span v-if="cycleData[dateCode]">
<i class="fitted green check mark icon"></i>
</span>
{{ dateCode.split('.').shift() }}
</div>
</div>
<div class="ui five wide column">
<div class="ui tiny basic compact button" v-on:click="openDayData(getPreviousDay)">
<i class="caret left icon"></i>
-1 Day
</div>
</div>
<div class="ui six wide center aligned column">
<div class="ui tiny basic compact button" v-on:click="openDayData(getToday)">
<i class="caret down icon"></i>
Today
</div>
</div>
<div class="ui five wide right aligned column">
<div class="ui tiny basic compact button" v-on:click="openDayData(getNextDay)">
+1 Day
<i class="caret right icon"></i>
</div>
</div>
</div>
<!-- data input -->
<div class="ui basic segment">
<div class="ui very compact grid">
<div class="four wide column"></div>
<div class="ui eight wide middle aligned center aligned column">
<div class="ui header">
<span :class="{'loading-day-title':loadingDay}">
{{ calendar.monthName }}
{{ calendar.today }}, {{ calendar.dayName }}
<span class="days-ago-display">
<!-- <span v-if="calendar.daysAgo == 0">Today</span> -->
<span v-if="calendar.daysAgo == 1"><br>{{ calendar.daysAgo }} day ago</span>
<span v-if="calendar.daysAgo > 1"><br>{{ calendar.daysAgo }} days ago</span>
</span>
</span>
</div>
</div>
<div class="ui four wide right aligned middle aligned column">
<span v-if="saving == 0">
<i class=" icon"></i>
</span>
<span v-if="saving == 1">
Modified <i class="asterisk icon"></i>
</span>
<span v-if="saving == 2">
Saving <i class="sync loading icon"></i>
</span>
<span v-if="saving == 3">
Saved <i class="green circle check icon"></i>
</span>
</div>
</div>
<div class="ui form" :class="{'loading-day':loadingDay}">
<draggable v-model="fields" class="ui compact grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable" :item-key="field => field">
<template #item="{ element: field }">
<div :class="userFields[field]?.width ? userFields[field]?.width+' wide stretched column':'eight wide stretched column'">
<div class="ui very compact grid input-grid">
<!-- field label display -->
<div class="row">
<div class="ui sixteen wide column">
<b><i :class="`${getFieldColor(field)} ${getFieldIcon(field)} icon`"></i>
{{ userFields[field]?.label }}</b>
<!-- <span>{{ field }}</span> -->
</div>
</div>
<!-- input display -->
<div class="row">
<div class="sixteen wide column">
<!-- float -->
<div v-if="userFields[field]?.type == 'float'" class="ui fluid input">
<input type="text" :placeholder="userFields[field]?.label" v-on:keyup="e => saveField(field, e.target.value)" :value="openDay[field]">
</div>
<!-- range -->
<div v-if="userFields[field]?.type == 'shortRange'">
<div :class="{green:(openDay[field] == 1)}" v-on:click="saveField(field, 1)" class="ui button">1</div>
<div :class="{green:(openDay[field] == 2)}" v-on:click="saveField(field, 2)" class="ui button">2</div>
<div :class="{green:(openDay[field] == 3)}" v-on:click="saveField(field, 3)" class="ui button">3</div>
<div :class="{green:(openDay[field] == 4)}" v-on:click="saveField(field, 4)" class="ui button">4</div>
<div :class="{green:(openDay[field] == 5)}" v-on:click="saveField(field, 5)" class="ui button">5</div>
</div>
<!-- text area -->
<div v-if="userFields[field]?.type == 'text'">
<textarea rows="3" v-on:keyup="e => saveField(field, e.target.value, 2000)" :value="openDay[field]">
</textarea>
</div>
<!-- boolean -->
<div v-if="userFields[field]?.type == 'boolean'">
<div class="option-buttons">
<div :class="{green:(openDay[field] == 1)}" v-on:click="saveField(field, 1)" class="ui compact button">Yes</div>
<div :class="{green:(openDay[field] == 2)}" v-on:click="saveField(field, 2)" class="ui compact button">No</div>
</div>
</div>
<div v-if="['sex','period','mucus','pms'].includes(userFields[field]?.type)">
<div class="option-buttons">
<div :class="{green:(openDay[field] == key)}" v-on:click="saveField(field, key)" class="ui compact button" v-for="(item,key) in fieldTypesDef[userFields[field]?.type].split(',')">{{ item }}</div>
</div>
</div>
<div v-if="userFields[field]?.type == 'custom'">
<div class="option-buttons">
<div
v-for="(item, value) in userFields[field]?.customOptions.split(',')"
v-on:click="saveField(field, value)"
:class="{green:(openDay[field] == value) && openDay[field] !== ''}"
class="ui compact button">
{{ item.trim() }}
</div>
</div>
</div>
</div>
</div>
<div class="bottom aligned row">
<div class="ui six wide column">
<span v-on:click="editField(field)">
<i class="clickable grey edit outline icon"></i>
</span>
</div>
<div class="four wide center aligned column draggable">
<span>
<i class="grey grip lines icon"></i>
</span>
</div>
<div class="ui six wide right aligned column">
<span v-if="openDay[field] !== ''" v-on:click="saveField(field, '')">
<i class="grey clickable reply icon"></i>
</span>
</div>
</div>
</div>
</div>
</template>
</draggable>
</div>
</div>
</section>
<!-- section 2 analysis -->
<section class="swipe-section" v-if="!loading">
<div class="ui small centered dividing header">
Review Data
<span class="sub header"><i class="small calendar alternate outline icon"></i>Overview of past metrics.</span>
</div>
<!-- calendar navigation -->
<div class="ui very compact grid">
<div class="ui four wide right aligned column" v-on:click="openDayData(getPreviousMonth)">
<span>
<i class="clickable caret square left outline icon"></i>
</span>
</div>
<div class="ui eight wide center aligned column">
<p>{{calendar.monthName}}, {{calendar.year}}</p>
</div>
<div class="ui four wide column">
<span v-on:click="openDayData(getNextMonth)">
<i class="clickable caret square right outline icon"></i>
</span>
</div>
</div>
<!-- calendar -->
<div class="calendar" v-if="false">
<div v-for="day in calendar.weekdays" class="day">
{{ day }}
</div>
<div v-for="day in calendar.days" class="day"
:class="{
'today':day == calendar.today,
'active-entry':calendar.dateCode == `${day}.${calendar.month}.${calendar.year}`,
'has-data':cycleData[`${day}.${calendar.month}.${calendar.year}`],
'no-data':showDayDataColor(day),
}"
v-on:click="openDayData(`${day}.${calendar.month}.${calendar.year}`)">
<span class="number">{{ day }}</span>
<!-- period display -->
<span v-if="isPeriod(day)" class="period"></span>
<!-- sex display -->
<span v-if="isSex(day) > 0" class="sex" >
<span v-if="isSex(day) == 1">
<i class="red heart icon"></i>
</span>
<span v-if="isSex(day) == 2">
<i class="red baby carriage icon"></i>
</span>
</span>
<!-- mucus display -->
<span v-if="isMucus(day)" class="mucus" :style="`top:${100-(isMucus(day)*22)}%`">
</span>
</div>
</div>
<div class="ui segment" v-if="false">
<a class="ui clickable" v-on:click="toggleFolded('key')">
<i class="tiny circular blue clickable plus icon"></i>
Calendar Explanation
</a>
</div>
<!-- calendar key -->
<modal v-if="folded.includes('key')" @close="toggleFolded('key')">
<div class="ui segment key-holder">
<div class="day">
<span class="number">1</span>
<span class="period"></span>
</div>
<span class="key-display">
Having Period
</span>
<div class="day">
<span class="number">1</span>
<span class="sex" >
<span>
<i class="red heart icon"></i>
</span>
</span>
</div>
<span class="key-display">
Sex with contraception
</span>
<div class="day">
<span class="number">1</span>
<span class="sex" >
<span>
<i class="red baby carriage icon"></i>
</span>
</span>
</div>
<span class="key-display">
Sex without contraception
</span>
<div class="day no-data">
<span class="number">1</span>
</div>
<span class="key-display">
No Data
</span>
<span class="key-display">
Amount/viscosity of cervical mucus
</span>
<div class="day" style="opacity: 0;"></div>
<div class="day">
<span class="number">1</span>
<span class="mucus" :style="`top:80%`">
</span>
</div>
<div class="day">
<span class="number">1</span>
<span class="mucus" :style="`top:60%`">
</span>
</div>
<div class="day">
<span class="number">1</span>
<span class="mucus" :style="`top:40%`">
</span>
</div>
<div class="day">
<span class="number">1</span>
<span class="mucus" :style="`top:20%`">
</span>
</div>
</div>
</modal>
<!-- Temp graph -->
<div class="ui basic segment" >
<div class="ui dividing header">
Chart data for the last {{ tempChartDays }} entries
</div>
<div class="ui tiny compact fluid buttons">
<div :class="{'green':(tempChartDays == 1000000)}" v-on:click="tempChartDays = 1000000; " class="ui button">ALL</div>
<div :class="{'green':(tempChartDays == 90)}" v-on:click="tempChartDays = 90; " class="ui button">90</div>
<div :class="{'green':(tempChartDays == 60)}" v-on:click="tempChartDays = 60; " class="ui button">60</div>
<div :class="{'green':(tempChartDays == 30)}" v-on:click="tempChartDays = 30; " class="ui button">30</div>
<div :class="{'green':(tempChartDays == 15)}" v-on:click="tempChartDays = 15; " class="ui button">15</div>
<div :class="{'green':(tempChartDays == 7)}" v-on:click="tempChartDays = 7; " class="ui button">7</div>
</div>
<div class="ui divider"></div>
<MetricGraphs
:key="'graph-updates-'+graphUpdates"
:tempChartDays="tempChartDays"
:fields="fields"
:userFields="userFields"
:graphs="graphs"
:cycleData="cycleData"
:calendar="calendar"
@saveGraphs="saveGraphs"
@toggleEditGraphs="toggleEditGraphs"
:editGraphs="editGraphs"
/>
<div class="ui very padded basic segment">
</div>
</div>
<!-- notes -->
<div class="ui basic segment" v-if="false">
<div class="ui clickable" v-on:click="toggleFolded('notes')">
<i class="tiny circular blue clickable plus icon"></i>
Additional Notes
</div>
<modal v-if="folded.includes('notes')" @close="toggleFolded('notes')">
<div class="ui fitted segments">
<div class="ui segment">
<div class="ui header">
Additional Notes
<div class="ui icon button" v-on:click="toggleFolded('notes')">
<i class="close icon"></i>
</div>
</div>
</div>
<div class="ui segment">
<a href="https://www.plannedparenthood.org/learn/birth-control/fertility-awareness">Further Reading on planned parenthood.</a>
</div>
<div class="ui segment">
Cervical mucus may not be a useful metric with an IUD or other forms of similar birth control.
</div>
<div class="ui segment">
Follicular phase
Luteal phase
</div>
<div class="ui segment">
Ovulation is detected by sustained temperate elevation. <br>
Ovulation can only be determined in hindsight, as progesterone is what makes the temperatures rise, and progesterone is only produced in abundance post-ovulation.
</div>
<div class="ui segment">
Some people may find that their temperature drops the day before or the day their period arrives. This is a very helpful sign in knowing that your period is on it’s way!
</div>
<div class="ui segment">
About halfway through your menstrual cycle, your hormones tell one of your ovaries to release a mature egg — this is called ovulation. Most people don’t feel it when they ovulate, but some ovulation symptoms are bloating, spotting, or a little pain in your lower belly that you may only feel on one side.
</div>
<div class="ui segment">
If you do get pregnant, your body needs the lining — that’s why your period stops during pregnancy. Your period comes back when you’re not pregnant anymore.
</div>
<div class="ui segment">
<i class="heart icon"></i>
</div>
</div>
</modal>
</div>
</section>
<!-- section 3 customization -->
<section class="swipe-section">
<div class="ui small centered dividing header">
Configure Fields
<span class="sub header"><i class="small tools icon"></i>Setup which metrics to track.</span>
</div>
<div class="ui basic segment">
<div class="ui basic segment">
<div class="ui tiny compact fluid green button" v-on:click="editField(null)">
<i class="plus icon"></i>
Create new Metric to track
</div>
</div>
<div class="ui small dividing header">
Active Fields
</div>
<draggable v-model="fields" class="ui very compact internally celled grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable" :item-key="fieldId => fieldId">
<template #item="{ element: fieldId }">
<div class="row">
<div class="eight wide column">
<i class="draggable grip lines icon"></i>
{{ userFields[fieldId]?.label }}
</div>
<div class="six wide column">
<span v-if="userFields[fieldId]?.type == 'custom'">
{{ userFields[fieldId]?.customOptions }}
</span>
<span v-else>
{{ userFields[fieldId]?.type }}
</span>
</div>
<div class="two wide column">
<div class="ui tiny compact button" v-on:click="stopUsingCustomField(fieldId)">Hide</div>
<!-- v-if="userFields[fieldId]?.type == 'custom'" -->
<div class="ui tiny compact button"
v-on:click="editField(fieldId)">Edit</div>
<!-- {{ openDay[fieldId] }} -->
</div>
</div>
</template>
</draggable>
<div class="ui small dividing header">
Other Fields
</div>
<div class="ui very compact internally celled grid">
<div class="ui row" v-for="(field,fieldId) in unusedUserFields" :key="fieldId">
<div class="eight wide column">
<i class="draggable grip lines icon"></i>
{{ userFields[fieldId]?.label }}
</div>
<div class="six wide column">
{{ userFields[fieldId]?.type }}
</div>
<div class="two wide column">
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
</div>
</div>
<div class="ui row" v-for="(field,fieldId) in unusedDefaultFields" :key="'default-'+fieldId">
<div class="eight wide column">
<i class="draggable grip lines icon"></i>
{{ defaultFields[fieldId]?.label }}
</div>
<div class="six wide column">
{{ defaultFields[fieldId]?.type }}
</div>
<div class="two wide column">
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
</div>
</div>
</div>
</div>
<!-- data management options -->
<div class="ui basic segment">
<div class="ui clickable" v-on:click="toggleFolded('datamanagement')">
<i class="tiny circular blue clickable plus icon"></i>
Data Management Options
</div>
<modal v-if="folded.includes('datamanagement')" @close="toggleFolded('datamanagement')">
<div class="ui segments">
<div class="ui segment">
<div class="ui small dividing header">
Backup All Metric Data
</div>
<div class="ui compact grid">
<div class="sixteen wide column">
<p>Backup your metric data for safe keeping or to restore later. This data is not encrypted when you download it.</p>
</div>
<div class="sixteen wide right aligned column">
<div class="ui green button" v-on:click="downloadAppState()">
<i class="download icon"></i>
Download Backup
</div>
</div>
</div>
</div>
<div class="ui segment">
<div class="ui small dividing header">
Import Past Metric Data
</div>
<div class="ui compact grid">
<div class="sixteen wide column">
<p>Restore data from a past backup. Paste the data from the file into the text area below and click import. Upon import, the data is encrypted in the system.</p>
</div>
<div class="sixteen wide column">
<div class="ui input">
<textarea cols="45" rows="5" v-model="appDataImport"></textarea>
</div>
</div>
<div class="sixteen wide right aligned column">
<div class="ui green button" v-on:click="importAppState()">
<i class="upload icon"></i>
Import Backup
</div>
</div>
</div>
</div>
<div class="ui segment">
<div class="ui small dividing header">
Delete All Metric Data
</div>
<div class="ui compact grid">
<div class="sixteen wide column">
<p>Permanently delete all metric data. This can not be undone. This will not delete your personal backups.</p>
</div>
<div class="sixteen wide right aligned column">
<delete-button v-if="noteId > 0" class="ui small icon button" :note-id="noteId" />
</div>
</div>
</div>
<div class="ui basic padded segment"></div>
</div>
</modal>
</div>
</section>
<!-- edit field modal -->
<modal v-if="editFieldId" @close="editFieldId = ''">
<div class="ui raised segment">
<div class="ui very compact grid">
<div class="ui small dividing header">
Add/Edit Field
</div>
<template v-for="(entry, key) in fieldDefinition" :key="key">
<div class="row" v-if="!['id'].includes(key)">
<div v-if="fieldDefinitionOptionsDef[key]" class="sixteen wide column">
{{ mapFormTerm(key) }}
<div v-if="fieldDefinitionOptionsDef[key].type == 'text'" class="ui fluid input">
<input type="text" :value="editFieldObject[key]" v-on:keyup="e => setNewFieldOption(e, key)">
</div>
<div v-if="fieldDefinitionOptionsDef[key].type == 'commatextoptions' && editFieldObject?.type == 'custom'" class="ui fluid input">
<input type="text" :value="editFieldObject[key]" v-on:keyup="e => setNewFieldOption(e, key)">
</div>
<div v-if="fieldDefinitionOptionsDef[key].type == 'option'">
<div v-for="option in fieldDefinitionOptionsDef[key].options"
v-on:click="setNewFieldOption(null, key, option)"
:class="{'green':editFieldObject[key] == option}"
class="ui button">
{{ mapFormTerm(option) }}
</div>
</div>
<div v-if="fieldDefinitionOptionsDef[key].type == 'icons'">
<div v-for="option in fieldDefinitionOptionsDef[key].options"
v-on:click="setNewFieldOption(null, key, option)"
:class="{'green':editFieldObject[key] == option}"
class="ui icon button">
<i :class="`${option} icon`"></i>
</div>
</div>
<!-- :class="{'green':}" -->
<div v-if="fieldDefinitionOptionsDef[key].type == 'color'">
<div v-for="option in fieldDefinitionOptionsDef[key].options"
v-on:click="setNewFieldOption(null, key, option)"
:class="`ui ${option} icon button`">
<i v-if="editFieldObject[key] == option" class="white check icon"></i>
</div>
</div>
</div>
</div>
</template>
<div class="row">
<div class="eight wide column">
<div class="ui fluid green button" v-on:click="saveCustomField(editFieldId)" v-if="validateCustomFieldsForm()">
Save it!
</div>
<div v-else class="ui fluid disabled green button">
Save it!
</div>
</div>
<div class="eight wide right aligned column">
<div class="ui button" v-on:click="editFieldId = ''">
<i class="trash icon"></i>
Cancel</div>
</div>
</div>
</div>
</div>
</modal>
<div class="ui basic segment"></div>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue'
/*
Important Notes:
+ Empty strings are the value used to represent NO DATA for an entry.
The app will not save empty string and the app will not display empty strings.
null, undefined, false are all represented as '' empty string
Any int -1,0,1,etc are valid values and will be used to represent metric state
*/
import axios from 'axios'
import draggable from 'vuedraggable'
export default {
name: 'MetricTracking',
// Descendant graph components need these; injecting avoids the
// $parent / $parent.$parent chains, which break whenever an
// intermediate instance is added (async wrappers, router-view).
provide() {
return {
getFieldColor: field => this.getFieldColor(field),
getFieldIcon: field => this.getFieldIcon(field),
getFieldLabel: field => this.getFieldLabel(field),
}
},
components: {
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
'delete-button': defineAsyncComponent(() => import('@/components/NoteDeleteButtonComponent.vue')),
'modal': defineAsyncComponent(() => import('@/components/ModalComponent.vue')),
draggable,
'MetricGraphs':defineAsyncComponent(() => import('@/components/Metrictracking/MetricGraphsComponent.vue')),
},
data () {
return {
setup: true,
noteId: 1, // Default to 1, 0 if not set, int if set
folded:[],
working: true,
showNotes: false,
fields:[], // Array of field IDs
userFields:{}, // Objects of field definitions
graphs:[],
fieldTypesDef:{
'float':'Precise Number',
'shortRange':'Options 1-5',
'longRange':'Options 1-10',
'text':'Text Input',
'boolean':'Yes, No',
'custom':'',
'period':'No, 1. Light, 2. Normal, 3. Heavy, 4. Irregular, 5. Painful',
'mucus':'None, 1. Watery, 2. Eggwhite, 3. Creamy, 4. Sticky',
'sex':'None, With contraception, Without contraception',
'pms':'No, 1. Maybe, 2. A little, 3. Real Cranky',
},
defaultFields:{
'BT': {'type':'float','label':'Basal Temp','icon':'thermometer half','width':'sixteen'},
'CM': {'type':'mucus','label':'Cervical Mucus','icon':'','width':'eight'},
'PE': {'type':'period','label':'Having Period','icon':'','width':'eight'},
'SE': {'type':'sex','label':'Sex','icon':'heart','width':'eight','color':'red'},
'NO': {'type':'text','label':'Notes','icon':'clipboard outline','width':'sixteen'},
'OV': {'type':'boolean','label':'Suspect Ovulation','icon':'','width':''},
'PM': {'type':'pms','label':'PMS','icon':'','width':''},
'HO': {'type':'boolean','label':'Horny','icon':'','width':''},
'DI': {'type':'text','label':'Diet','icon':'','width':''},
'EX': {'type':'text','label':'Exercise','icon':'','width':''},
'SB': {'type':'boolean','label':'Sore Boobs','icon':'','width':''},
},
fieldDefinition:{
'id':'','type':'','label':'','icon':'','color':'','width':'','customOptions':''
},
fieldDefinitionOptionsDef:{
label:{'type':'text'},
customOptions:{'type':'commatextoptions'},
icon:{'type':'icons', 'options':['heart','smile','cat','crow','dog','dove','dragon','feather','feather alternate','fish','frog','hippo','horse','horse head','kiwi bird','otter','paw','spider','video','headphones','motorcycle','truck','monster truck','campground','cloud sun','drumstick bite','football ball','fruit-apple','hiking','mountain','tractor','tree','wind','wine bottle','coffee','flask','glass cheers','glass martini','beer','toilet paper','gift','globe','hand holding heart','comment','graduation cap','hat cowboy','hat wizard','mitten','user tie','laptop code','microchip','shield alternate','mouse','plug','power off','satellite','hammer','wrench','bell','eye','marker','paperclip','atom','award','theater masks','music','grin alternate','grin tongue squint outline','laugh wink','fire','fire alternate','poop','sun','money bill alternate','piggy bank','heart outline','heartbeat','running','walking','bacon','bone','bread slice','candy cane','carrot','cheese','cloud meatball','cookie','egg','hamburger','hotdog','ice cream','lemon','lemon outline','pepper hot','pizza slice','seedling','stroopwafel','leaf','book dead','broom','cloud moon','ghost','mask','skull crossbones','certificate','check','check circle','joint','cannabis','bong','gem','futbol','brain','dna','hand spock','hand spock outline','meteor','moon','moon outline','robot','rocket','satellite dish','space shuttle','user astronaut','fingerprint','thumbs up','thumbs down']},
color:{'type':'color', 'options':['red','orange','yellow','olive','green','teal','blue','violet','purple','pink','brown','grey','black'],'optional':true},
width:{'type':'option', 'options':['four','eight','sixteen']},
},
cycleData: {},
totalEntries: 0,
openDay: {}, // current day values, updates into cycleData when saved
loadingDayTimeout: null,
loadingDay: false,
saveDataDebounce:null,
loading: true,
saving: 0, // 0 blank, 1 modified, 2 saving, 3 saved
calendar: {
dateObject: null,
dateCode: null,
monthName: '',
dayName:'',
daysAgo:0,
month: '',
year: '',
days: [],
weekdays: ['S','M','T','W','T','F','S'],
today: 0,
},
tempChartDays: 30,
//Field Editing
// openEditField: false,
editFieldId:'',
editFieldObject:{},
appDataImport:'',
graphUpdates:0,
editGraphs: false,
}
},
beforeCreate() {
// Perform Login check
this.$root.loginGateway()
},
mounted(){
if(!this.$store.getters.getLoggedIn){
return
}
// instantly hide metric tracking reminder if user visits page
this.$store.commit('hideMetricTrackingReminder')
this.$bus.$on('note_deleted', () => {
location.reload();
})
// setup date to today
this.setupCalendar()
this.fetchCycleData()
// scroll past entries quick access all the way right
this.$refs?.pastEntries
.scrollBy({
top: 0,
left: 900,
behavior: 'smooth'
});
},
computed: {
// vuedraggable's computeNodes walks the default slot's vnodes directly
// and cannot handle the fragments that <template v-for> produces, so
// the filtering happens here instead of with a v-if in the slot.
unusedUserFields(){
return Object.fromEntries(
Object.entries(this.userFields).filter(([fieldId]) => !this.fields.includes(fieldId))
)
},
unusedDefaultFields(){
const userFieldIds = Object.keys(this.userFields)
return Object.fromEntries(
Object.entries(this.defaultFields).filter(([fieldId]) =>
!this.fields.includes(fieldId) && !userFieldIds.includes(fieldId))
)
},
getToday(){
return this.generateDateCode(new Date())
},
getPreviousDay(){
const workingDate = this.calendar.dateObject || new Date()
workingDate.setDate(workingDate.getDate()-1)
return this.generateDateCode(workingDate)
},
getNextDay(){
const workingDate = this.calendar.dateObject || new Date()
workingDate.setDate(workingDate.getDate()+1)
return this.generateDateCode(workingDate)
},
getNextMonth(){
const workingDate = this.calendar.dateObject || new Date()
workingDate.setMonth(workingDate.getMonth() +1)
return this.generateDateCode(workingDate)
},
getPreviousMonth(){
const workingDate = this.calendar.dateObject || new Date()
workingDate.setMonth(workingDate.getMonth() -1)
return this.generateDateCode(workingDate)
},
getPastThirtyDayCodes(){
let codes = []
let workingDate = new Date()
const todayCode = this.generateDateCode(workingDate)
codes.push(todayCode)
for (let i = 0; i < 30; i++) {
workingDate.setDate(workingDate.getDate() - 1)
const newCode = this.generateDateCode(workingDate)
codes.push(newCode)
}
return codes.reverse()
}
},
methods: {
saveGraphs(newGraphData){
this.graphs = newGraphData
this.saveCycleData()
// re-render graph data on update
this.$nextTick(() => {
this.graphUpdates++
})
},
toggleEditGraphs(){
this.editGraphs = !this.editGraphs
},
getFieldColor(field){
let color = null
// user defined
if(this.userFields[field]?.color){
color = this.userFields[field].color
}
// default field value
if(!color && this.defaultFields[field]?.color){
color = this.defaultFields[field]?.color
}
// globe fallback
if(!color){
color = ''
}
return color
// userFields[field]?.icon
},
getFieldLabel(field){
let label = null
// user defined
if(this.userFields[field]?.label){
label = this.userFields[field].label
}
// default field value
if(!label && this.defaultFields[field]?.label){
label = this.defaultFields[field]?.label
}
// fall back to the raw id
if(!label){
label = field
}
return label
},
getFieldIcon(field){
let icon = null
// user defined
if(this.userFields[field]?.icon){
icon = this.userFields[field].icon
}
// default field value
if(!icon && this.defaultFields[field]?.icon){
icon = this.defaultFields[field]?.icon
}
// globe fallback
if(!icon){
icon = 'globe'
}
return icon
// userFields[field]?.icon
},
scrollApp(direction){
this.$refs?.scrollcontainer
.scrollBy({
top: 0,
left: direction,
behavior: 'smooth'
});
},
importAppState(){
if(!this.appDataImport){
return
}
try {
const newAppJson = {
'text': this.appDataImport,
'id': this.noteId // reuse note id
}
this.setApplicationStateJson(newAppJson)
this.saveCycleData()
this.appDataImport = ''
this.$bus.$emit('notification', 'Imported new Data')
setTimeout(() => {
location.reload(true)
}, 1500)
} catch(e){
this.$bus.$emit('notification', 'Failed to Import Data')
}
},
downloadAppState(){
const a = document.createElement("a");
const json = this.getApplicationStateJson()
const blob = new Blob([json], {type: "octet/stream"})
const url = window.URL.createObjectURL(blob)
const dlTime = new Date().toLocaleString().replaceAll('/','-').replaceAll(':','.').replaceAll(',','')
a.href = url;
a.download = `metric-tracking-data-${dlTime}.json`
a.click();
window.URL.revokeObjectURL(url);
setTimeout(() => {
a.remove()
}, 500)
},
validateCustomFieldsForm(){
const checks = [] //check
const checkFields = Object.keys(this.fieldDefinitionOptionsDef)
checkFields.forEach(row => {
// console.log(this.editFieldObject[row])
// don't worry about optional fields
if(this.fieldDefinitionOptionsDef[row].optional){
checks.push(true)
return
}
// Make sure value is set for custom fields
if(!this.editFieldObject[row] && this.editFieldObject['type'] == 'custom'){
checks.push(false)
return
}
// Verify at least two options
if(row == 'customOptions' && this.editFieldObject['type'] == 'custom'){
const entries = this.editFieldObject[row].split(',').map(e => e.trim()).filter(e => e)
if(entries.length <= 1){
checks.push(false)
}
}
// Make sure field is not empty
if(this.editFieldObject[row] != ''){
checks.push(true)
return
}
checks.push(false)
})
return checks.every(check => check == true) && checks.length == checkFields.length
},
setNewFieldOption(event, key, option){
if(event?.target?.value){
option = event.target.value
}
this.editFieldObject[key] = option
},
mapFormTerm(term){
// term in form, display value
const termMap = [
'four, Quarter Width',
'eight, Half Width',
'sixteen, Full Width',
'label, Name of Metric',
'icon, Metric Display Icon',
'width, Width of field',
'customOptions, Comma Separated set of options will appear as buttons'
]
// map input term to dictionary and return it
for (var i = 0; i < termMap.length; i++) {
if(termMap[i].indexOf(term) === 0){
return termMap[i].split(',').pop().trim()
}
}
return term
},
editField(fieldId){
const randomId = Math.random().toString(36).toUpperCase().slice(2, 7)
fieldId = fieldId || randomId
let editField = this.editFieldObject
// Edit existing user field
if(this.userFields[fieldId]){
console.log('Edit user field')
editField = this.userFields[fieldId]
}
// edit existing default field
if(this.defaultFields[fieldId]){
console.log('Edit default field')
editField = this.defaultFields[fieldId]
}
if(Object.keys(editField) == 0){
console.log('This is a new field!')
this.editFieldObject['type'] = 'custom'
}
this.editFieldId = fieldId
this.editFieldObject = editField
},
saveCustomField(fieldId){
fieldId = fieldId || this.editFieldId
// if(customOptions){
//
// }
// Add new field if it doesn't exist
if(!this.fields.includes(fieldId)){
this.fields.push(fieldId)
}
this.userFields[fieldId] = this.editFieldObject
this.editFieldId = ''
this.editFieldObject = {}
this.saveCycleData()
},
stopUsingCustomField(fieldId){
const removeIndex = this.fields.indexOf(fieldId)
if(removeIndex != -1){
this.fields.splice(removeIndex,1)
this.saveCycleData()
}
},
startUsingCustomField(fieldId){
let newField = ''
if(this.defaultFields[fieldId]){
newField = this.defaultFields[fieldId]
}
if(this.userFields[fieldId]){
newField = this.userFields[fieldId]
}
// setup new fields
this.fields.push(fieldId)
this.userFields[fieldId] = newField
this.saveCycleData()
},
deleteCustomField(fieldId){
const removeIndex = this.fields.indexOf(fieldId)
if(removeIndex != -1){
this.fields.splice(removeIndex,1)
delete this.userFields[fieldId]
}
},
onDragEnd(){
this.saveCycleData()
},
toggleFolded(key){
const index = this.folded.indexOf(key)
if(index == -1){
this.folded.push(key)
return
}
this.folded.splice(index,1)
},
showDayDataColor(day){
// Determine if day has any data set
if(day == ''){
return false
}
return !(this.cycleData[`${day}.${this.calendar.month}.${this.calendar.year}`])
},
isPeriod(day){
const data = this.cycleData[`${day}.${this.calendar.month}.${this.calendar.year}`]
if(data?.PE > 0){
return true
}
},
isSex(day){
const data = this.cycleData[`${day}.${this.calendar.month}.${this.calendar.year}`]
return data?.SE
},
isMucus(day){
const data = this.cycleData[`${day}.${this.calendar.month}.${this.calendar.year}`]
return data?.CM
},
isNotes(day){
const data = this.cycleData[`${day}.${this.calendar.month}.${this.calendar.year}`]
return data?.NO
},
isTemp(day){
const data = this.cycleData[`${day}.${this.calendar.month}.${this.calendar.year}`]
return data?.BT
},
fieldAdd(fieldId){
this.fields.push(fieldId)
this.saveCycleData()
},
saveField(fieldId, value, optionalTimeout){
// Dont save value if it hasn't changed
if(this.openDay[fieldId] === value){ return }
// update field to be reactive
this.openDay[fieldId] = value
// Push straight into cycleData so the graphs redraw immediately;
// the server write below stays debounced.
this.applyOpenDayToCycleData()
// remove debounce and set to modified
this.$nextTick(() => {
//0 blank, 1 modified, 2 saving, 3 saved
this.saving = 1
clearTimeout(this.saveDataDebounce)
this.saveDataDebounce = setTimeout(() => {
this.saveDayData()
}, optionalTimeout || 500)
})
},
openDayData(dateCode){
if(String(dateCode).split('.').length != 3){
console.log('Invalid Date Code')
return
}
this.loadingDay = true
clearTimeout(this.loadingDayTimeout)
this.loadingDayTimeout = setTimeout(() => {
this.loadingDay = false
}, 500)
this.setupCalendar(this.dateCodeToDate(dateCode))
// open day has all fields defined, just set values
let currentDay = this.cycleData[dateCode] || {}
Object.keys(this.openDay).forEach(fieldId => {
this.openDay[fieldId] = currentDay[fieldId]
})
},
// Local-only: fold the open day's values into cycleData. Cheap and
// synchronous, so callers can run it on every keystroke.
applyOpenDayToCycleData(){
// remove empty keys
let cleanDayData = {}
Object.keys(this.openDay).forEach(key => {
if(this.openDay[key] !== ''){
cleanDayData[key] = this.openDay[key]
}
})
// Only keep entry if there is data
delete this.cycleData[this.calendar.dateCode]
if(Object.keys(cleanDayData).length > 0){
this.cycleData[this.calendar.dateCode] = cleanDayData
}
},
saveDayData(){
this.applyOpenDayToCycleData()
this.saveCycleData()
},
// Create a new metric tracking note
setupMetricTracking(){
axios.post('/api/metric-tracking/create')
.then(({ data }) => {
this.setApplicationStateJson(data)
})
.catch(error => {
this.$bus.$emit('notification', error)
this.setApplicationStateJson(null)
})
},
// Fetch metric tracking note
fetchCycleData(){
axios.post('/api/metric-tracking/get')
.then(({ data }) => {
this.loading = false
this.setApplicationStateJson(data)
})
.catch(error => {
this.$bus.$emit('notification', error)
this.setApplicationStateJson(null)
})
},
// Set up vue application state based on metric tracking note data
setApplicationStateJson(data){
let json = null
if(data.hasOwnProperty("text")){
try {
json = JSON.parse(data.text)
} catch(e) {
console.log('Could not parse metric tracking data')
}
}
// Setting up note shows app or setup screen
this.noteId = data.id || 0
// Set application state with JSON object
// Can be called via import or Ajax on load
this.cycleData = json?.cycleData || this.cycleData
this.fields = [...new Set(json?.fields)] || this.fields
this.userFields = json?.userFields || this.userFields
this.graphs = json?.graphs || this.graphs
this.$nextTick(() => {
this.getApplicationStateJson()
this.totalEntries = Object.keys(this.cycleData).length
this.setupFields()
this.openDayData(this.calendar.dateCode)
this.generateTonsOfRandomData()
})
},
getApplicationStateJson(){
// convert date code into sortable int
const sortableDate = (code) => {
code = code
.split('.')
.reverse()
.map(i => String(i).padStart(2, '0'))
.join('')
return parseInt(code)
}
// Sort cycle data, newest first
let sortedData = Object.keys(this.cycleData)
.sort((a,b) => {
return sortableDate(b) - sortableDate(a)
})
// setup new object with sorted data
let sortedCycleData = sortedData.reduce((result, key) => {
result[key] = this.cycleData[key]
return result
},{})
return JSON.stringify({
fields: this.fields,
cycleData: sortedCycleData,
userFields: this.userFields,
graphs: this.graphs,
})
},
saveCycleData(){
const appData = this.getApplicationStateJson()
// 0 blank, 1 modified, 2 saving, 3 saved
this.saving = 2 // Working
this.totalEntries = Object.keys(this.cycleData).length
axios.post('/api/metric-tracking/save', { cycleData:appData })
.then(response => {
// { this.$bus.$emit('notification', 'Data Saved') }
this.saving = 3 //Saved
setTimeout(() => {
this.saving = 0 //Reset
}, 2000)
})
.catch(error => { this.$bus.$emit('notification', error) })
},
setupFields(){
// set up reactive open day object
Object.keys(this.userFields).forEach(fieldId => {
this.openDay[fieldId] = ''
})
//
// This basically behaves as a data migration function
// Old user data can not be deleted and must be cleaned/maintained
//
// push the first 3 default fields to users set
if(this.fields.length == 0){
const fieldKeys = Object.keys(this.defaultFields)
console.log('Setup default fierds')
for (let i = 0; i < 5; i++) {
this.fields.push(fieldKeys[i])
}
}
// define field data from default fields
if(Object.keys(this.userFields).length == 0){
this.fields.forEach(fieldId => {
let newField = this.defaultFields[fieldId]
this.userFields[fieldId] = {...newField}
})
}
},
getDayByIndex(index){
const workingDate = new Date()
workingDate.setDate(workingDate.getDate() + index)
return this.generateDateCode(workingDate)
},
generateDateCode(date){
const dateSetup = [
date.getDate(), // 1-31 (Day)
date.getMonth()+1, // 0-11 (Month)
date.getFullYear(), // 1888-2022 (Year)
]
return dateSetup.join('.')
},
dateCodeToDate(dateCode){
const dateChunk = dateCode.split('.')
return new Date(dateChunk[2], dateChunk[1]-1, dateChunk[0])
},
setupCalendar(date){
// visualize each day change
this.working = true
setTimeout(() => {
this.working = false
}, 500)
if(!date && this.dateObject){
date = this.dateObject
}
if(!date){
date = new Date()
}
this.calendar.dateObject = date
this.calendar.dateCode = this.generateDateCode(date)
// calculate days ago since current date
const now = new Date()
const diffSeconds = Math.floor((now - date) / 1000) // subtract unix timestamps, convert MS to S
const dayInterval = diffSeconds / 86400 // seconds in a day
this.calendar.daysAgo = Math.floor(dayInterval)
// ------------
// setup calendar display
var y = date.getFullYear()
var m = date.getMonth()
var firstDay = new Date(y, m, 1);
var lastDay = new Date(y, m + 1, 0);
function getDaysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
const currentYear = date.getFullYear();
const currentMonth = date.getMonth() + 1;
this.calendar.monthName = date.toLocaleString("en-US", { month: "long" });
this.calendar.dayName = date.toLocaleString("en-US", { weekday: "long" });
this.calendar.year = currentYear
const daysInCurrentMonth = getDaysInMonth(currentYear, currentMonth);
const monthStartDay = firstDay.getDay()
let days = Array(monthStartDay).fill(""); // Pad days to start on correct weekday
for (let i = 0; i < daysInCurrentMonth; i++) {
days.push(i+1)
}
this.calendar.days = days
// set today
this.calendar.today = date.getDate()
this.calendar.month = date.getMonth()+1
/*
October 2022
S M T W T F S
1 2 3 4 5 6
7 8 9
*/
// -------
},
generateTonsOfRandomData(){
return
let workingDate = new Date()
for (var i = 0; i < 365 * 2; i++) {
const cycleTime = (i%30)+1
const randomInt = Math.floor(Math.random() * cycleTime+20) + (cycleTime);
let randomTemp = parseFloat(`97.${randomInt}`)
const randomFive = Math.floor(Math.random() * 4) + 0;
const randomHundo = Math.floor(Math.random() * 100) + 1;
const randUnoOrDuo = Math.floor(Math.random() * 2) + 1;
this.cycleData[this.generateDateCode(workingDate)] = {
'BT':randomTemp,
'CM':randomFive,
'SE':randomHundo > 90 ? randUnoOrDuo : 0,
}
workingDate.setDate(workingDate.getDate()-1)
}
},
}
}
</script>