1747 lines
50 KiB
Vue
1747 lines
50 KiB
Vue
<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> </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> |