Tons of fixes to prepare for staged releases.
This commit is contained in:
1 parent
9f37d3faf1
commit
5bebe072b2
24 files changed
+1110
-474
No files matched your search
@@ -11,12 +11,14 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"axios": "^1.1.3",
|
||||
"chart.js": "^4.6.1",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fomantic-ui-css": "^2.9.0",
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^2.5.0",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
"vue-router": "^4.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuex": "^4.0.0"
|
||||
|
||||
Generated
+36
-4
@@ -13,7 +13,10 @@ importers:
|
||||
version: 4.1.18([email protected]([email protected])([email protected]))
|
||||
axios:
|
||||
specifier: ^1.1.3
|
||||
version: 1.18.1
|
||||
version: 1.18.1([email protected])
|
||||
chart.js:
|
||||
specifier: ^4.6.1
|
||||
version: 4.6.1
|
||||
diff-match-patch:
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5
|
||||
@@ -32,6 +35,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.5.18
|
||||
version: 3.5.26
|
||||
vue-chartjs:
|
||||
specifier: ^5.3.4
|
||||
version: 5.3.4([email protected])([email protected])
|
||||
vue-router:
|
||||
specifier: ^4.0.0
|
||||
version: 4.6.4([email protected])
|
||||
@@ -240,6 +246,9 @@ packages:
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@kurkle/[email protected]':
|
||||
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
||||
|
||||
@@ -530,6 +539,10 @@ packages:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g==}
|
||||
engines: {pnpm: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -895,6 +908,12 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==}
|
||||
peerDependencies:
|
||||
chart.js: ^4.1.1
|
||||
vue: ^3.0.0-0 || ^2.7.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||
peerDependencies:
|
||||
@@ -1049,6 +1068,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@kurkle/[email protected]': {}
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
|
||||
'@rollup/[email protected]':
|
||||
@@ -1261,9 +1282,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
follow-redirects: 1.16.0([email protected])
|
||||
form-data: 4.0.6
|
||||
https-proxy-agent: 5.0.1
|
||||
proxy-from-env: 2.1.0
|
||||
@@ -1282,6 +1303,10 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -1397,7 +1422,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
[email protected]: {}
|
||||
[email protected]([email protected]):
|
||||
optionalDependencies:
|
||||
debug: 4.4.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -1636,6 +1663,11 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.30.2
|
||||
|
||||
[email protected]([email protected])([email protected]):
|
||||
dependencies:
|
||||
chart.js: 4.6.1
|
||||
vue: 3.5.26
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
|
||||
@@ -1001,3 +1001,8 @@ padding-right: 10px;
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
|
||||
i.outline {
|
||||
outline-style: none;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="line-graph-holder">
|
||||
<Line v-if="hasData" :data="chartData" :options="chartOptions" />
|
||||
<div v-else class="no-data">No data for the selected range.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Line } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
|
||||
|
||||
// Categorical slots, validated for both of this app's surfaces (#fff / #000)
|
||||
// with scripts/validate_palette.js. Assigned by a field's stable position,
|
||||
// never by its position in a particular graph, so toggling fields on and off
|
||||
// never repaints the ones that stay.
|
||||
const SERIES = {
|
||||
light: ['#2a78d6','#eb6834','#1baf7a','#eda100','#e87ba4','#008300','#4a3aa7','#e34948'],
|
||||
dark: ['#3987e5','#d95926','#199e70','#c98500','#d55181','#008300','#9085e9','#e66767'],
|
||||
}
|
||||
|
||||
// A field can carry an explicit Fomantic colour name chosen by the user; that
|
||||
// is their intent and it matches the field's icon elsewhere on the page, so it
|
||||
// wins over the generated slot.
|
||||
const FOMANTIC = {
|
||||
red:'#DB2828', orange:'#F2711C', yellow:'#FBBD08', olive:'#B5CC18',
|
||||
green:'#21BA45', teal:'#00B5AD', blue:'#2185D0', violet:'#6435C9',
|
||||
purple:'#A333C8', pink:'#E03997', brown:'#A5673F', grey:'#767676',
|
||||
black:'#1B1C1D',
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MetricLineGraph',
|
||||
inject: ['getFieldColor'],
|
||||
components: { Line },
|
||||
props: {
|
||||
graph: { type: Object, required: true }, // { fieldIds: [...] }
|
||||
userFields: { type: Object, default: () => ({}) },
|
||||
cycleData: { type: Object, default: () => ({}) },
|
||||
tempChartDays: { type: [Number, String], default: 30 },
|
||||
},
|
||||
computed: {
|
||||
isDark(){
|
||||
return Number(this.$store.getters.getIsNightMode) === 1
|
||||
},
|
||||
surface(){
|
||||
return this.isDark ? '#000000' : '#ffffff'
|
||||
},
|
||||
textColor(){
|
||||
// Text wears text tokens, never the series colour.
|
||||
return this.isDark ? '#d9d9d9' : '#3d3d3d'
|
||||
},
|
||||
gridColor(){
|
||||
// One step off the surface, hairline and recessive.
|
||||
return this.isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.10)'
|
||||
},
|
||||
fieldIds(){
|
||||
return this.graph?.fieldIds || []
|
||||
},
|
||||
// Every date present in the data, oldest first, trimmed to the
|
||||
// requested window. Sorting by real date rather than object key
|
||||
// order means out-of-order writes cannot scramble the line.
|
||||
dateCodes(){
|
||||
const codes = Object.keys(this.cycleData)
|
||||
.map(code => ({ code, time: this.codeToDate(code) }))
|
||||
.filter(entry => !isNaN(entry.time))
|
||||
.sort((a, b) => a.time - b.time)
|
||||
.map(entry => entry.code)
|
||||
|
||||
const span = parseInt(this.tempChartDays)
|
||||
if(!span || span < 1 || span >= codes.length){ return codes }
|
||||
return codes.slice(-span)
|
||||
},
|
||||
hasData(){
|
||||
return this.fieldIds.length > 0 && this.dateCodes.length > 0
|
||||
},
|
||||
chartData(){
|
||||
return {
|
||||
labels: this.dateCodes.map(code => this.formatLabel(code)),
|
||||
datasets: this.fieldIds.map(fieldId => {
|
||||
const colour = this.colourFor(fieldId)
|
||||
return {
|
||||
label: this.userFields[fieldId]?.label || fieldId,
|
||||
data: this.dateCodes.map(code => this.valueAt(code, fieldId)),
|
||||
borderColor: colour,
|
||||
backgroundColor: colour,
|
||||
borderWidth: 2,
|
||||
tension: 0.25,
|
||||
// A marker that is drawn is >= 8px with a 2px ring in
|
||||
// the surface colour; dense series only show them on
|
||||
// hover so the line stays readable.
|
||||
pointRadius: this.denseData ? 0 : 4,
|
||||
pointHoverRadius: 5,
|
||||
pointBorderColor: this.surface,
|
||||
pointBorderWidth: 2,
|
||||
pointHoverBorderColor: this.surface,
|
||||
pointHoverBorderWidth: 2,
|
||||
pointHitRadius: 12,
|
||||
spanGaps: false, // a missing day is a gap, not a zero
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
denseData(){
|
||||
return this.dateCodes.length > 40
|
||||
},
|
||||
chartOptions(){
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 200 },
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: this.textColor, maxRotation: 0, autoSkipPadding: 16 },
|
||||
border: { color: this.gridColor },
|
||||
},
|
||||
y: {
|
||||
grid: { color: this.gridColor, lineWidth: 1, drawTicks: false },
|
||||
ticks: { color: this.textColor, padding: 8 },
|
||||
border: { display: false },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
// Always present: identity is never carried by colour alone,
|
||||
// and it is the relief for the low-contrast light slots.
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: this.textColor,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
padding: 14,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: this.isDark ? '#1b1b1b' : '#ffffff',
|
||||
titleColor: this.textColor,
|
||||
bodyColor: this.textColor,
|
||||
borderColor: this.gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
usePointStyle: true,
|
||||
callbacks: {
|
||||
label: ctx => {
|
||||
const v = ctx.parsed.y
|
||||
return `${ctx.dataset.label}: ${v === null ? '—' : v}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Date codes are stored as D.M.YYYY
|
||||
codeToDate(code){
|
||||
const [d, m, y] = String(code).split('.').map(Number)
|
||||
return new Date(y, (m || 1) - 1, d || 1).getTime()
|
||||
},
|
||||
formatLabel(code){
|
||||
const date = new Date(this.codeToDate(code))
|
||||
if(isNaN(date.getTime())){ return code }
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
},
|
||||
valueAt(code, fieldId){
|
||||
const raw = this.cycleData[code]?.[fieldId]
|
||||
if(raw === undefined || raw === null || raw === ''){ return null }
|
||||
const num = Number(raw)
|
||||
// Non-numeric field types cannot be plotted on a value axis; a gap
|
||||
// is honest, where the old code substituted -1 and bent the scale.
|
||||
return isNaN(num) ? null : num
|
||||
},
|
||||
colourFor(fieldId){
|
||||
const named = this.getFieldColor ? this.getFieldColor(fieldId) : null
|
||||
if(named && FOMANTIC[named]){ return FOMANTIC[named] }
|
||||
|
||||
// Stable slot: keyed off the field's position in userFields, so the
|
||||
// colour follows the field rather than its rank in this graph.
|
||||
const all = Object.keys(this.userFields)
|
||||
const index = all.indexOf(fieldId)
|
||||
const slots = this.isDark ? SERIES.dark : SERIES.light
|
||||
return slots[(index < 0 ? 0 : index) % slots.length]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-graph-holder {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
.no-data {
|
||||
padding: 2em 0;
|
||||
text-align: center;
|
||||
color: var(--menu-text, #767676);
|
||||
}
|
||||
</style>
|
||||
@@ -43,12 +43,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="sixteen wide column">
|
||||
<dix class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui button" v-on:click="toggleEditGraphs">
|
||||
<i class="edit icon"></i>
|
||||
<span>Add/Edit Graphs</span>
|
||||
</div>
|
||||
</dix>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="editGraphs">
|
||||
@@ -64,7 +64,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment animate-height`">
|
||||
<div class="ui basic center aligned segment" v-if="!graphs || graphs.length == 0">
|
||||
<h4 class="ui header">No graphs yet</h4>
|
||||
<p>Choose <b>Add/Edit Graphs</b>, then <b>New Graph</b>, and pick which metrics to plot.</p>
|
||||
</div>
|
||||
|
||||
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment`">
|
||||
|
||||
<!-- Edit options -->
|
||||
<div class="ui small header" v-if="editGraphs">
|
||||
@@ -126,15 +131,20 @@
|
||||
<span v-if="!editGraphs">Click "Edit Graphs" then,</span>
|
||||
Select Graph type and Metrics to display
|
||||
</div>
|
||||
<div v-if="graph?.type == undefined && graph.fieldIds && graph.fieldIds.length > 0">
|
||||
<div :id="`graphdiv${index}`" style="width: 100%; min-height: 320px;"></div>
|
||||
<div v-if="isLineGraph(graph) && graph.fieldIds && graph.fieldIds.length > 0">
|
||||
<LineGraph
|
||||
:graph="graph"
|
||||
:userFields="userFields"
|
||||
:cycleData="cycleData"
|
||||
:tempChartDays="tempChartDays"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ui segment" v-if="editGraphs">
|
||||
|
||||
<!-- change graph type -->
|
||||
<div v-for="(graphType, graphId) in graphTypesDef" class="ui buttons">
|
||||
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':(String(graphId) == String(graph?.type))}">
|
||||
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':isGraphTypeActive(graphId, graph)}">
|
||||
{{ graphType }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,12 +170,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="sixteen wide column">
|
||||
<dix class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui button" v-on:click="toggleEditGraphs">
|
||||
<i class="edit icon"></i>
|
||||
<span>Add/Edit Graphs</span>
|
||||
</div>
|
||||
</dix>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="editGraphs">
|
||||
@@ -208,6 +218,7 @@ import { defineAsyncComponent } from 'vue'
|
||||
],
|
||||
components: {
|
||||
'PillCalendarGraph':defineAsyncComponent(() => import('@/components/Metrictracking/PillCalendarGraph.vue')),
|
||||
'LineGraph':defineAsyncComponent(() => import('@/components/Metrictracking/LineGraph.vue')),
|
||||
},
|
||||
data: function(){
|
||||
return {
|
||||
@@ -223,33 +234,9 @@ import { defineAsyncComponent } from 'vue'
|
||||
// Constants
|
||||
this.PILL_CALENDAR = PILL_CALENDAR
|
||||
this.LAST_DONE = LAST_DONE
|
||||
|
||||
// Include JS libraries
|
||||
let graphsScript = document.createElement('script')
|
||||
graphsScript.setAttribute('src', '//cdnjs.cloudflare.com/ajax/libs/dygraph/2.1.0/dygraph.min.js')
|
||||
document.head.appendChild(graphsScript)
|
||||
},
|
||||
mounted(){
|
||||
this.localGraphData = this.graphs
|
||||
|
||||
this.graphCurrentData()
|
||||
},
|
||||
updated(){
|
||||
// update graphs here? Or watch graphs prop
|
||||
},
|
||||
watch: {
|
||||
// whenever question changes, this function will run
|
||||
userFields(newFields, oldFields) {
|
||||
// console.log([newFields, oldFields])
|
||||
if( JSON.stringify(oldFields) == "{}" ){
|
||||
this.graphCurrentData()
|
||||
}
|
||||
},
|
||||
tempChartDays(newDays, oldDays){
|
||||
if( newDays != oldDays ){
|
||||
this.graphCurrentData()
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveGraphs(){
|
||||
@@ -269,10 +256,35 @@ import { defineAsyncComponent } from 'vue'
|
||||
this.$emit('toggleEditGraphs')
|
||||
},
|
||||
changeGraphType(index, newType){
|
||||
console.log(index + ' change to ' + newType)
|
||||
this.localGraphData[index]['type'] = newType
|
||||
|
||||
// graphTypesDef is an object, so its keys are strings and the
|
||||
// default line graph arrives here as the STRING 'undefined'.
|
||||
// Saved graphs represent the line graph as an absent type, so
|
||||
// remove the key rather than storing a string that no render
|
||||
// test can match.
|
||||
if(this.isLineGraphType(newType)){
|
||||
delete this.localGraphData[index].type
|
||||
}
|
||||
else {
|
||||
this.localGraphData[index].type = newType
|
||||
}
|
||||
|
||||
this.saveGraphs()
|
||||
},
|
||||
// A line graph is the default: an absent type. Accept the string
|
||||
// form too so graphs saved by the older code still render.
|
||||
isLineGraphType(type){
|
||||
return type === undefined || type === null || type === '' || type === 'undefined'
|
||||
},
|
||||
isLineGraph(graph){
|
||||
return this.isLineGraphType(graph?.type)
|
||||
},
|
||||
isGraphTypeActive(graphId, graph){
|
||||
if(this.isLineGraphType(graphId)){
|
||||
return this.isLineGraph(graph)
|
||||
}
|
||||
return String(graphId) === String(graph?.type)
|
||||
},
|
||||
addGraph(){
|
||||
this.localGraphData.push({})
|
||||
this.saveGraphs()
|
||||
@@ -331,103 +343,6 @@ import { defineAsyncComponent } from 'vue'
|
||||
|
||||
return title
|
||||
},
|
||||
graphCurrentData(){
|
||||
|
||||
// try again if dygraphs isn't loaded
|
||||
if( typeof(window.Dygraph) != 'function' ){
|
||||
setTimeout(() => {
|
||||
this.graphCurrentData()
|
||||
}, 100)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const graphOptions = {
|
||||
interactionModel: {},
|
||||
// pointClickCallback: function(e, pt){
|
||||
// console.log(e)
|
||||
// console.log(pt)
|
||||
// console.log(this.getValue(pt.idx, 0))
|
||||
// }
|
||||
}
|
||||
|
||||
// Excel date format YYYYMMDD
|
||||
const convertToExcelDate = (dateCode) => {
|
||||
return dateCode
|
||||
.split('.')
|
||||
.reverse()
|
||||
.map(item => String(item).padStart(2,0))
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Generate set of keys for graph length
|
||||
let dataKeys = Object.keys(this.cycleData)
|
||||
dataKeys = dataKeys.splice(0, this.tempChartDays)
|
||||
console.log(dataKeys)
|
||||
|
||||
|
||||
// build CSV data for each graph
|
||||
this.graphs.forEach((graph,index) => {
|
||||
|
||||
// only chart line graphs with dygraphs
|
||||
if( graph.type != undefined ){
|
||||
return
|
||||
}
|
||||
if( !graph.fieldIds ){
|
||||
return
|
||||
}
|
||||
|
||||
// CSV or path to a CSV file.
|
||||
let dataString = ""
|
||||
|
||||
// Lookup graph field titles
|
||||
let graphLabels = ['Date']
|
||||
graph.fieldIds.forEach(fieldId => {
|
||||
const graphLabel = this.userFields[fieldId]?.label
|
||||
const escapedLabel = graphLabel.replaceAll(',','')
|
||||
graphLabels.push(escapedLabel)
|
||||
})
|
||||
dataString += graphLabels.join(',') + '\n'
|
||||
|
||||
|
||||
// build each row, for each day
|
||||
for (var i = 0; i < dataKeys.length; i++) {
|
||||
|
||||
let nextFragment = []
|
||||
// push date code to first column
|
||||
nextFragment.push(convertToExcelDate(dataKeys[i]))
|
||||
|
||||
graph.fieldIds.forEach(fieldId => {
|
||||
|
||||
const currentEntry = this.cycleData[dataKeys[i]]
|
||||
let currentValue = currentEntry[fieldId]
|
||||
|
||||
// setup correct float graphing
|
||||
if(fieldId == 'BT'){
|
||||
// parse temp to fixed length float 00.00
|
||||
currentValue = parseFloat(currentValue).toFixed(2)
|
||||
}
|
||||
|
||||
if( currentValue == undefined ){
|
||||
currentValue = -1
|
||||
}
|
||||
|
||||
nextFragment.push(currentValue)
|
||||
|
||||
})
|
||||
|
||||
dataString += nextFragment.join(',') + "\n"
|
||||
}
|
||||
|
||||
|
||||
let graphDiv = document.getElementById("graphdiv"+index)
|
||||
const g = new Dygraph(graphDiv, dataString ,graphOptions)
|
||||
|
||||
})
|
||||
|
||||
return
|
||||
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -81,6 +81,13 @@
|
||||
|
||||
}
|
||||
|
||||
.days-since {
|
||||
padding: 0.75em 0.5em 0.25em 0.5em;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.days-since-row {
|
||||
padding: 0.15em 0;
|
||||
}
|
||||
.pill-container {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -262,6 +269,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- time since each metric was last recorded -->
|
||||
<div class="days-since" v-if="daysSince.length > 0">
|
||||
<div class="days-since-row" v-for="entry in daysSince" :key="entry.fieldId">
|
||||
<i :class="`${entry.color} ${entry.icon} icon`"></i>
|
||||
<span v-if="entry.days === null">Days Since {{ entry.label }} - no entries yet</span>
|
||||
<span v-else>Days Since {{ entry.label }} - {{ entry.days }} {{ entry.days === 1 ? 'day' : 'days' }}.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -272,7 +287,7 @@
|
||||
// let chartData = {}
|
||||
|
||||
export default {
|
||||
inject: ['getFieldColor', 'getFieldIcon'],
|
||||
inject: ['getFieldColor', 'getFieldIcon', 'getFieldLabel'],
|
||||
props: [
|
||||
'graph', // options associated with this graph
|
||||
'userFields', // all field attributes
|
||||
@@ -313,6 +328,53 @@
|
||||
this.setupCalendar(new Date())
|
||||
},
|
||||
computed: {
|
||||
// "Days Since <metric> - <n> days" for every field on this graph.
|
||||
// Counts a day as recorded only if it passes the same
|
||||
// showZeroValuesCheck the pills use, so the "Show/Hide Lowest Value"
|
||||
// toggle moves these numbers too.
|
||||
daysSince(){
|
||||
|
||||
// showZeroValuesCheck reads lowestGraphValue, which getChartData
|
||||
// assigns as a side effect -- touch it so the threshold is current
|
||||
// and so this recomputes whenever the chart data does.
|
||||
void this.getChartData
|
||||
|
||||
const dayMs = 86400000
|
||||
const midnight = (time) => {
|
||||
const date = new Date(time)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime()
|
||||
}
|
||||
const today = midnight(Date.now())
|
||||
|
||||
return (this.graph?.fieldIds || []).map(fieldId => {
|
||||
|
||||
let latest = null
|
||||
|
||||
Object.keys(this.cycleData || {}).forEach(dateCode => {
|
||||
|
||||
const value = this.cycleData[dateCode]?.[fieldId]
|
||||
if(value === undefined || value === null || value === ''){ return }
|
||||
|
||||
// honour the Lowest Value toggle
|
||||
if(!this.showZeroValuesCheck(value, fieldId)){ return }
|
||||
|
||||
const time = this.dateCodeToTime(dateCode)
|
||||
// ignore unparseable codes and anything dated ahead of today
|
||||
if(time === null || time > today){ return }
|
||||
|
||||
if(latest === null || time > latest){ latest = time }
|
||||
})
|
||||
|
||||
return {
|
||||
fieldId,
|
||||
label: this.getFieldLabel(fieldId),
|
||||
icon: this.getFieldIcon(fieldId),
|
||||
color: this.getFieldColor(fieldId),
|
||||
days: latest === null ? null : Math.round((today - latest) / dayMs),
|
||||
}
|
||||
})
|
||||
},
|
||||
getChartData(){
|
||||
|
||||
let chartData = {}
|
||||
@@ -393,11 +455,26 @@
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Date codes are stored unpadded as D.M.YYYY
|
||||
dateCodeToTime(dateCode){
|
||||
|
||||
const parts = String(dateCode).split('.').map(Number)
|
||||
if(parts.length !== 3 || parts.some(isNaN)){ return null }
|
||||
|
||||
const [day, month, year] = parts
|
||||
const date = new Date(year, month - 1, day)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
|
||||
return isNaN(date.getTime()) ? null : date.getTime()
|
||||
},
|
||||
showZeroValuesCheck(dayValue, fieldId){
|
||||
|
||||
// Optional chaining: a graph can reference a default field (BT,
|
||||
// CM, ...) that was never customised, so it has no userFields
|
||||
// entry and the bare lookup threw.
|
||||
// if graph type is boolean or there are two options
|
||||
let isBooleanField = this.userFields[fieldId].type == 'boolean'
|
||||
if(this.userFields[fieldId].customOptions){
|
||||
let isBooleanField = this.userFields[fieldId]?.type == 'boolean'
|
||||
if(this.userFields[fieldId]?.customOptions){
|
||||
let options = this.userFields[fieldId].customOptions
|
||||
|
||||
isBooleanField = options.split(',').length == 2
|
||||
@@ -415,6 +492,13 @@
|
||||
}
|
||||
|
||||
|
||||
// lowestGraphValue comes from Math.min of the charted values, so
|
||||
// it is Infinity when the window holds no data -- without the
|
||||
// finite guard nothing would ever clear the threshold.
|
||||
if(!isFinite(this.lowestGraphValue)){
|
||||
return true
|
||||
}
|
||||
|
||||
return this.showZeroValues || dayValue > this.lowestGraphValue
|
||||
},
|
||||
getDayValue(fieldId, value){
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<div class="menu-bottom-half" :class="{ 'hide-text':(openNotes > 1) }">
|
||||
|
||||
<!--
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/table`)" data-tooltip="Insert Table" data-position="bottom center">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('table')" data-tooltip="Insert Table" data-position="bottom center">
|
||||
<i class="border all icon"></i>
|
||||
</div> -->
|
||||
|
||||
@@ -87,13 +87,13 @@
|
||||
<div class="edit-divide"></div>
|
||||
|
||||
<!--
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/colors`)" data-tooltip="Note Color" data-position="top center" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('colors')" data-tooltip="Note Color" data-position="top center" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
|
||||
<i class="paint brush icon"></i>
|
||||
</div> -->
|
||||
<!-- <div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/tags`)" data-tooltip="Tags" data-position="top center">
|
||||
<!-- <div class="edit-button" v-on:click="openMenuRoute('tags')" data-tooltip="Tags" data-position="top center">
|
||||
<i class="tags icon"></i>
|
||||
</div> -->
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/images`)" data-tooltip="Images" data-position="top center">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('images')" data-tooltip="Images" data-position="top center">
|
||||
<i class="image icon"></i>
|
||||
<span>Images</span>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
<div class="edit-divide"></div>
|
||||
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/options`)" data-tooltip="More Options" data-position="top center">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('options')" data-tooltip="More Options" data-position="top center">
|
||||
|
||||
<i class="ellipsis horizontal icon"></i>
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
|
||||
<!-- tags on the side, only show on desktop -->
|
||||
<div class="note-mini-tag-area"
|
||||
v-on:click="$router.push(`/notes/open/${noteid}/menu/tags`)"
|
||||
v-on:click="openMenuRoute('tags')"
|
||||
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }">
|
||||
|
||||
<span class="add-mini-tag" v-if="allTags.length == 0">
|
||||
@@ -201,7 +201,7 @@
|
||||
|
||||
<!-- Squire Box -->
|
||||
<div
|
||||
id="squire-id"
|
||||
:id="`squire-id-${noteid}`"
|
||||
class="squire-box"
|
||||
ref="squirebox"
|
||||
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }"
|
||||
@@ -369,7 +369,6 @@ import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import SquireButtonFunctions from '@/mixins/SquireButtonFunctions.js'
|
||||
|
||||
let rawNoteText = '' // Used for comparing and generating diffs
|
||||
|
||||
export default {
|
||||
name: 'NoteInputPanel',
|
||||
@@ -419,6 +418,9 @@ import { defineAsyncComponent } from 'vue'
|
||||
|
||||
//All the squire settings
|
||||
editor: null,
|
||||
// Baseline for diff generation. Per-instance: as a module-scoped
|
||||
// variable this was shared by every open note's panel.
|
||||
rawNoteText: '',
|
||||
usersOnNote: 0,
|
||||
|
||||
sideMenuOpen: false,
|
||||
@@ -475,25 +477,17 @@ import { defineAsyncComponent } from 'vue'
|
||||
},
|
||||
beforeMount(){
|
||||
|
||||
this.$bus.$on('new_file_upload', ({noteId, imageCode}) => {
|
||||
if(this.noteid == noteId && this.editor){
|
||||
this.editor.moveCursorToEnd()
|
||||
this.editor.insertHTML(imageCode)
|
||||
this.save()
|
||||
}
|
||||
})
|
||||
|
||||
this.$bus.$on('close_note_by_id', (noteId) => {
|
||||
if(noteId == this.noteid){
|
||||
this.closeButtonAction()
|
||||
}
|
||||
})
|
||||
// Named handlers so beforeUnmount can remove only THIS panel's.
|
||||
// mitt's off(type) with no handler removes every panel's.
|
||||
this.$bus.$on('new_file_upload', this.onFileUpload)
|
||||
this.$bus.$on('close_note_by_id', this.onCloseNoteById)
|
||||
},
|
||||
beforeUnmount(){
|
||||
|
||||
this.$io.emit('leave_room', this.rawTextId)
|
||||
this.$io.emit('leave_room', { noteId: this.noteid })
|
||||
|
||||
this.$bus.$off('new_file_upload')
|
||||
this.$bus.$off('new_file_upload', this.onFileUpload)
|
||||
this.$bus.$off('close_note_by_id', this.onCloseNoteById)
|
||||
|
||||
this.destroyAttachmentStyles()
|
||||
|
||||
@@ -533,13 +527,13 @@ import { defineAsyncComponent } from 'vue'
|
||||
},
|
||||
methods: {
|
||||
testModify(){
|
||||
const text = document.getElementById('squire-id').children[0].innerHTML
|
||||
const text = this.$refs.squirebox.children[0].innerHTML
|
||||
const prepended = 'Hello-> ' + text
|
||||
document.getElementById('squire-id').children[0].innerHTML = prepended
|
||||
this.$refs.squirebox.children[0].innerHTML = prepended
|
||||
|
||||
const text1 = document.getElementById('squire-id').children[2].innerHTML
|
||||
const text1 = this.$refs.squirebox.children[2].innerHTML
|
||||
const prepended1 = 'Hey-> ' + text1
|
||||
document.getElementById('squire-id').children[2].innerHTML = prepended1
|
||||
this.$refs.squirebox.children[2].innerHTML = prepended1
|
||||
},
|
||||
simulateTyping(index = 0){
|
||||
|
||||
@@ -593,7 +587,7 @@ import { defineAsyncComponent } from 'vue'
|
||||
this.setText(this.noteText)
|
||||
|
||||
// Use squire box HTML for diff/patch changes
|
||||
rawNoteText = document.getElementById('squire-id').innerHTML
|
||||
this.rawNoteText = this.$refs.squirebox.innerHTML
|
||||
|
||||
//focus on open, not on mobile, it causes the keyboard to pop up, thats annoying
|
||||
if(!this.$store.getters.getIsUserOnMobile){
|
||||
@@ -867,6 +861,8 @@ import { defineAsyncComponent } from 'vue'
|
||||
},
|
||||
//Called on squire event for keyup
|
||||
diffText(event){
|
||||
console.log('Skipping diff text function')
|
||||
return
|
||||
// console.log(event.type)
|
||||
|
||||
const diffEvents = ['keyup','pathChange', 'click']
|
||||
@@ -880,17 +876,17 @@ import { defineAsyncComponent } from 'vue'
|
||||
this.diffTextTimeout = setTimeout(() => {
|
||||
|
||||
// Current Editor Text
|
||||
const liveEditorElm = document.getElementById('squire-id')
|
||||
const liveEditorElm = this.$refs.squirebox
|
||||
|
||||
// virtual element for selecting div
|
||||
let virtualEditorElm = document.createElement('div')
|
||||
virtualEditorElm.innerHTML = rawNoteText
|
||||
virtualEditorElm.innerHTML = this.rawNoteText
|
||||
|
||||
// element at cursor
|
||||
const elmAtCaret = window.getSelection().getRangeAt(0).startContainer.parentNode
|
||||
|
||||
// Remove beginngin selector from path, make it more generic
|
||||
const path = this.generateSelector(elmAtCaret).replace('div#squire-id > ','')
|
||||
const path = this.generateSelector(elmAtCaret).replace(`div#squire-id-${this.noteid} > `,'')
|
||||
let workingPath = ''
|
||||
|
||||
// default to entire note text, select down if path
|
||||
@@ -938,10 +934,10 @@ import { defineAsyncComponent } from 'vue'
|
||||
const patch_text = dmp.patch_toText(patch_list)
|
||||
|
||||
// save raw text for future diffs
|
||||
rawNoteText = liveEditorElm.innerHTML
|
||||
this.rawNoteText = liveEditorElm.innerHTML
|
||||
|
||||
let newPatch = {
|
||||
id: this.rawTextId,
|
||||
noteId: this.noteid,
|
||||
diff: patch_text,
|
||||
path: path,
|
||||
// testing metrics
|
||||
@@ -961,7 +957,10 @@ import { defineAsyncComponent } from 'vue'
|
||||
// console.log('incoming patches ', incomingPatchs)
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const editorElement = document.getElementById('squire-id')
|
||||
const editorElement = this.$refs.squirebox
|
||||
|
||||
// Socket-driven: this panel may not be rendered yet, or may be gone
|
||||
if(!editorElement){ return resolve(false) }
|
||||
|
||||
// iterate over incoming patches because they apply to specific divs
|
||||
incomingPatchs.forEach(patch => {
|
||||
@@ -988,10 +987,10 @@ import { defineAsyncComponent } from 'vue'
|
||||
})
|
||||
|
||||
// save editor HTML after change for future comparisons
|
||||
rawNoteText = editorElement.innerHTML
|
||||
this.rawNoteText = editorElement.innerHTML
|
||||
|
||||
// update hash on patch
|
||||
this.lastNoteHash = this.hashString( rawNoteText )
|
||||
this.lastNoteHash = this.hashString( this.rawNoteText )
|
||||
|
||||
this.$nextTick(() => {
|
||||
return resolve(true)
|
||||
@@ -1041,7 +1040,7 @@ import { defineAsyncComponent } from 'vue'
|
||||
|
||||
//tell websockets to truncate history at this save
|
||||
this.lastNoteHash = currentHash //Update last saved note hash
|
||||
this.$io.emit('truncate_diffs_at_save', {'rawTextId':this.rawTextId, 'hash':currentHash })
|
||||
this.$io.emit('truncate_diffs_at_save', {'noteId':this.noteid, 'hash':currentHash })
|
||||
|
||||
const postData = {
|
||||
'noteId': this.currentNoteId,
|
||||
@@ -1093,7 +1092,8 @@ import { defineAsyncComponent } from 'vue'
|
||||
this.setupLoadedNoteData(response)
|
||||
|
||||
//Manually set squire text
|
||||
const editorElement = document.getElementById('squire-id')
|
||||
const editorElement = this.$refs.squirebox
|
||||
if(!editorElement){ return }
|
||||
editorElement.innerHTML = response.data.text
|
||||
|
||||
this.$nextTick(() => {
|
||||
@@ -1146,6 +1146,15 @@ import { defineAsyncComponent } from 'vue'
|
||||
|
||||
return hash;
|
||||
},
|
||||
// Menu routes are pushed (not replaced) because the history entry is
|
||||
// load-bearing: every menu closes via $router.go(-1). Forwarding the
|
||||
// current query keeps ?open= alive across the menu and the pop back.
|
||||
openMenuRoute(menu){
|
||||
this.$router.push({
|
||||
path: `/notes/open/${this.noteid}/menu/${menu}`,
|
||||
query: this.$route.query,
|
||||
})
|
||||
},
|
||||
closeButtonAction(playAnimation = false){
|
||||
this.sizeDown = playAnimation
|
||||
const animationTimeout = (playAnimation ? 300 : 0)
|
||||
@@ -1181,66 +1190,106 @@ import { defineAsyncComponent } from 'vue'
|
||||
})
|
||||
},
|
||||
destroyWebSockets(){
|
||||
this.$io.removeListener('past_diffs')
|
||||
this.$io.removeListener('update_user_count')
|
||||
this.$io.removeListener('incoming_diff')
|
||||
this.$io.removeListener('update_note_attachments')
|
||||
|
||||
// By reference: removeListener(event) alone would remove the other
|
||||
// open panels' handlers too. new_note_text_saved was never removed
|
||||
// at all and leaked a handler on every open/close.
|
||||
this.$io.removeListener('update_user_count', this.onUpdateUserCount)
|
||||
this.$io.removeListener('past_diffs', this.onPastDiffs)
|
||||
this.$io.removeListener('incoming_diff', this.onIncomingDiff)
|
||||
this.$io.removeListener('new_note_text_saved', this.onNewNoteTextSaved)
|
||||
this.$io.removeListener('update_note_attachments', this.onUpdateNoteAttachments)
|
||||
|
||||
clearTimeout(this.idleNoteTimeout)
|
||||
},
|
||||
// Socket handlers are named methods rather than inline closures so that
|
||||
// destroyWebSockets() can remove THIS panel's handler by reference.
|
||||
// removeListener(event) with no handler drops every open panel's.
|
||||
onFileUpload({noteId, imageCode}){
|
||||
if(this.noteid == noteId && this.editor){
|
||||
this.editor.moveCursorToEnd()
|
||||
this.editor.insertHTML(imageCode)
|
||||
this.save()
|
||||
}
|
||||
},
|
||||
onCloseNoteById(noteId){
|
||||
if(noteId == this.noteid){
|
||||
this.closeButtonAction()
|
||||
}
|
||||
},
|
||||
onUpdateUserCount(userCount){
|
||||
this.usersOnNote = userCount
|
||||
},
|
||||
//Apply all diffs since last save
|
||||
onPastDiffs(diffSinceLastUpdate){
|
||||
|
||||
// Every open panel shares one socket, so filter to this note's room
|
||||
if(!this.isDiffForThisNote(diffSinceLastUpdate)){ return }
|
||||
|
||||
if(diffSinceLastUpdate != null){
|
||||
this.diffsApplied = diffSinceLastUpdate.length
|
||||
}
|
||||
|
||||
this.patchText(diffSinceLastUpdate)
|
||||
.then(() => {
|
||||
this.restoreCaretPosition()
|
||||
.catch(e => console.log(e) )
|
||||
})
|
||||
},
|
||||
onIncomingDiff(incomingDiff){
|
||||
|
||||
if(!this.isDiffForThisNote([incomingDiff])){ return }
|
||||
|
||||
this.patchText([incomingDiff])
|
||||
.then(() => {
|
||||
this.restoreCaretPosition()
|
||||
.catch(e => console.log(e) )
|
||||
})
|
||||
},
|
||||
// A diff payload carries the raw-text id it belongs to. Anything without
|
||||
// one is dropped rather than guessed at -- applying a patch to the wrong
|
||||
// note is how note bodies got clobbered.
|
||||
isDiffForThisNote(diffs){
|
||||
|
||||
if(diffs == null){ return false }
|
||||
|
||||
const list = [].concat(diffs).filter(diff => diff != null)
|
||||
if(list.length == 0){ return false }
|
||||
|
||||
return list.every(diff => String(diff.rawTextId) === String(this.rawTextId))
|
||||
},
|
||||
onNewNoteTextSaved({noteId, hash}){
|
||||
|
||||
if(this.statusText != 'saved'){
|
||||
return
|
||||
}
|
||||
|
||||
const sameIdCheck = (this.idleNote && this.noteid == noteId)
|
||||
const differentHashCheck = (hash != this.lastNoteHash)
|
||||
|
||||
// if hashes do not match, reload text from server
|
||||
if(sameIdCheck && differentHashCheck){
|
||||
this.loadNoteNextFromServer()
|
||||
}
|
||||
},
|
||||
onUpdateNoteAttachments(){
|
||||
this.getAttachments()
|
||||
},
|
||||
initWebsocketEvents(){
|
||||
|
||||
//Tell server to push this note into a room
|
||||
this.$io.emit('join_room', this.rawTextId )
|
||||
|
||||
this.$io.on('update_user_count', userCount => {
|
||||
this.usersOnNote = userCount
|
||||
})
|
||||
|
||||
//Apply all diffs since last save
|
||||
this.$io.on('past_diffs', diffSinceLastUpdate => {
|
||||
|
||||
if(diffSinceLastUpdate != null){
|
||||
this.diffsApplied = diffSinceLastUpdate.length
|
||||
// console.log('Got Diffs Total -> ', diffSinceLastUpdate)
|
||||
}
|
||||
console.log(diffSinceLastUpdate)
|
||||
this.patchText(diffSinceLastUpdate)
|
||||
.then(() => {
|
||||
this.restoreCaretPosition()
|
||||
.catch(e => console.log(e) )
|
||||
})
|
||||
})
|
||||
|
||||
this.$io.on('incoming_diff', incomingDiff => {
|
||||
|
||||
this.patchText([incomingDiff])
|
||||
.then(() => {
|
||||
this.restoreCaretPosition()
|
||||
.catch(e => console.log(e) )
|
||||
})
|
||||
})
|
||||
|
||||
this.$io.on('new_note_text_saved', ({noteId, hash}) => {
|
||||
|
||||
if(this.statusText != 'saved'){
|
||||
return
|
||||
}
|
||||
|
||||
const sameIdCheck = (this.idleNote && this.noteid == noteId)
|
||||
const differentHashCheck = (hash != this.lastNoteHash)
|
||||
|
||||
// if hashes do not match, reload text from server
|
||||
if(sameIdCheck && differentHashCheck){
|
||||
this.loadNoteNextFromServer()
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
this.$io.on('update_note_attachments', () => {
|
||||
this.getAttachments()
|
||||
// Ask for a note, not a room. The server authorises against the
|
||||
// token and derives the room itself.
|
||||
this.$io.emit('join_room', {
|
||||
noteId: this.noteid,
|
||||
token: localStorage.getItem('loginToken'),
|
||||
})
|
||||
|
||||
this.$io.on('update_user_count', this.onUpdateUserCount)
|
||||
this.$io.on('past_diffs', this.onPastDiffs)
|
||||
this.$io.on('incoming_diff', this.onIncomingDiff)
|
||||
this.$io.on('new_note_text_saved', this.onNewNoteTextSaved)
|
||||
this.$io.on('update_note_attachments', this.onUpdateNoteAttachments)
|
||||
},
|
||||
logNoteInteraction(){
|
||||
|
||||
@@ -1253,6 +1302,8 @@ import { defineAsyncComponent } from 'vue'
|
||||
},
|
||||
saveCaretPosition(event){
|
||||
|
||||
return
|
||||
|
||||
//Find index of child element based on past range
|
||||
const element = window.getSelection().getRangeAt(0).startContainer.parentNode
|
||||
|
||||
@@ -1271,7 +1322,9 @@ import { defineAsyncComponent } from 'vue'
|
||||
|
||||
if(!this.lastRange){ return resolve(true) }
|
||||
|
||||
const editorElement = document.getElementById('squire-id')
|
||||
const editorElement = this.$refs.squirebox
|
||||
if(!editorElement){ return resolve(true) }
|
||||
|
||||
const lastElement = editorElement.querySelector(this.lastRange)
|
||||
|
||||
if( !lastElement ){ return resolve(true) }
|
||||
|
||||
@@ -17,6 +17,9 @@ import 'fomantic-ui-css/components/label.min.css'
|
||||
|
||||
import './assets/semantic-helper.css'
|
||||
|
||||
// Last, so Tailwind utilities win specificity ties against Fomantic
|
||||
import './tailwind.css'
|
||||
|
||||
import io from 'socket.io-client'
|
||||
import EventBus from './EventBus'
|
||||
import Helpers from './Helpers'
|
||||
|
||||
@@ -171,7 +171,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
this.$router.go(-1)
|
||||
|
||||
setTimeout(()=>{
|
||||
@@ -189,7 +189,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
|
||||
//Close menu if user is on mobile, then sort list
|
||||
this.$router.go(-1)
|
||||
@@ -249,7 +249,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
|
||||
//Close menu if user is on mobile
|
||||
this.$router.go(-1)
|
||||
@@ -317,7 +317,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
|
||||
//Close menu if user is on mobile, then sort list
|
||||
this.$router.go(-1)
|
||||
@@ -377,7 +377,7 @@ const SquireButtonFunctions = {
|
||||
// this.diffNoteText = this.editor._getHTML()
|
||||
|
||||
//Make sure all list items have draggable property
|
||||
// let container = document.getElementById('squire-id')
|
||||
// let container = this.$refs.squirebox
|
||||
// let listItems = container.getElementsByTagName('li')
|
||||
// for(let itemIndex in listItems){
|
||||
// // console.log(listItems[itemIndex])
|
||||
|
||||
@@ -355,9 +355,9 @@
|
||||
</div>
|
||||
|
||||
<div class="ui form" :class="{'loading-day':loadingDay}">
|
||||
<draggable v-model="fields" class="ui compact grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable">
|
||||
<div v-for="field in fields" :key="field"
|
||||
:class="userFields[field]?.width ? userFields[field]?.width+' wide stretched column':'eight wide stretched column'">
|
||||
<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 -->
|
||||
@@ -443,6 +443,7 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
@@ -599,7 +600,6 @@
|
||||
<div :class="{'green':(tempChartDays == 7)}" v-on:click="tempChartDays = 7; " class="ui button">7</div>
|
||||
</div>
|
||||
|
||||
<!-- <div id="graphdiv" style="width: 100%; min-height: 320px;"></div> -->
|
||||
<div class="ui divider"></div>
|
||||
|
||||
<MetricGraphs
|
||||
@@ -686,8 +686,9 @@
|
||||
<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">
|
||||
<div v-for="fieldId in fields" :key="fieldId" class="row">
|
||||
<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 }}
|
||||
@@ -708,14 +709,14 @@
|
||||
<!-- {{ openDay[fieldId] }} -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div class="ui small dividing header">
|
||||
Other Fields
|
||||
</div>
|
||||
<draggable class="ui very compact internally celled grid">
|
||||
<template v-for="(field,fieldId) in userFields" :key="fieldId">
|
||||
<div class="ui row" v-if="!fields.includes(fieldId)">
|
||||
<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 }}
|
||||
@@ -727,10 +728,7 @@
|
||||
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-for="(field,fieldId) in defaultFields" :key="fieldId">
|
||||
<div class="ui row"
|
||||
v-if="!fields.includes(fieldId) && !Object.keys(userFields).includes(fieldId)">
|
||||
<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>
|
||||
@@ -743,8 +741,7 @@
|
||||
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -886,8 +883,6 @@
|
||||
</div>
|
||||
</modal>
|
||||
|
||||
<!-- may or may not need digraph CSS, maybe disect -->
|
||||
<!-- <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/dygraph/2.1.0/dygraph.min.css" /> -->
|
||||
|
||||
<div class="ui basic segment"></div>
|
||||
|
||||
@@ -920,6 +915,7 @@ import { defineAsyncComponent } from 'vue'
|
||||
return {
|
||||
getFieldColor: field => this.getFieldColor(field),
|
||||
getFieldIcon: field => this.getFieldIcon(field),
|
||||
getFieldLabel: field => this.getFieldLabel(field),
|
||||
}
|
||||
},
|
||||
components: {
|
||||
@@ -1036,6 +1032,21 @@ import { defineAsyncComponent } from 'vue'
|
||||
});
|
||||
},
|
||||
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())
|
||||
},
|
||||
@@ -1115,6 +1126,27 @@ import { defineAsyncComponent } from 'vue'
|
||||
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
|
||||
@@ -1389,6 +1421,10 @@ import { defineAsyncComponent } from 'vue'
|
||||
// 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
|
||||
@@ -1422,7 +1458,9 @@ import { defineAsyncComponent } from 'vue'
|
||||
|
||||
|
||||
},
|
||||
saveDayData(){
|
||||
// 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 = {}
|
||||
@@ -1432,12 +1470,15 @@ import { defineAsyncComponent } from 'vue'
|
||||
}
|
||||
})
|
||||
|
||||
// Only save entry if there is data
|
||||
// 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()
|
||||
|
||||
},
|
||||
|
||||
@@ -206,6 +206,9 @@ import { defineAsyncComponent } from 'vue'
|
||||
searchTags: [],
|
||||
notes: [],
|
||||
openNotes: [],
|
||||
// fullPath of our own last URL write, so the $route watcher can
|
||||
// recognise it and not treat it as an inbound navigation
|
||||
lastSyncedFullPath: null,
|
||||
collapseFloatingList: false,
|
||||
highlights: [],
|
||||
searchDebounce: null,
|
||||
@@ -383,11 +386,26 @@ import { defineAsyncComponent } from 'vue'
|
||||
},
|
||||
mounted() {
|
||||
|
||||
//Open note on PAGE LOAD if ID is set
|
||||
if(this.$route.params.id > 1){
|
||||
this.openNote(this.$route.params.id)
|
||||
// Restore every note named in ?open=
|
||||
const ids = this.parseOpenQuery(this.$route.query.open)
|
||||
|
||||
// Fold the legacy /notes/open/:id deep link in, first position
|
||||
const paramId = parseInt(this.$route.params.id, 10)
|
||||
if(Number.isInteger(paramId) && paramId > 1 && !ids.includes(paramId)){
|
||||
ids.unshift(paramId)
|
||||
}
|
||||
|
||||
// Push directly rather than via openNote(), which would write the URL
|
||||
// once per note; one normalising write follows instead.
|
||||
ids.forEach(id => {
|
||||
if(!this.openNotes.includes(id)){
|
||||
this.openNotes.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Unconditional, so a junk ?open=abc gets scrubbed too
|
||||
this.syncOpenNotesToUrl()
|
||||
|
||||
//Loads initial batch and tags
|
||||
this.reset()
|
||||
|
||||
@@ -398,17 +416,31 @@ import { defineAsyncComponent } from 'vue'
|
||||
},
|
||||
'$route' (to, from) {
|
||||
|
||||
// Our own URL write, not an inbound navigation
|
||||
if(to.fullPath === this.lastSyncedFullPath){ return }
|
||||
|
||||
// Reload the notes if returning to this page
|
||||
if(to.fullPath == '/notes' && !from.fullPath.includes('/notes/open/')){
|
||||
// The discriminator is the absence of ?open=, NOT the path. A menu's
|
||||
// $router.go(-1) lands on /notes?open=5 with `from` containing
|
||||
// '/notes/open/', so a path comparison here would close every note
|
||||
// every time a side menu is dismissed.
|
||||
const landingOnBareNotes = (to.path === '/notes' && !to.query.open)
|
||||
|
||||
// Reload the notes if arriving at an empty /notes from elsewhere
|
||||
if(landingOnBareNotes && !from.path.startsWith('/notes/open/') && from.path !== '/notes'){
|
||||
this.reset()
|
||||
}
|
||||
|
||||
// Close all notes if returning to /notes page
|
||||
if(to.fullPath == '/notes' && from.fullPath.includes('/notes/open/')){
|
||||
// Explicit navigation to a note-less /notes closes everything
|
||||
if(landingOnBareNotes && this.openNotes.length > 0){
|
||||
this.closeAllNotes()
|
||||
}
|
||||
|
||||
// Stale history entry (e.g. go(-1) after another note was closed):
|
||||
// rewrite the URL from the array. Never read it.
|
||||
if(to.path === '/notes' && to.query.open && to.query.open !== this.openNotes.join(',')){
|
||||
this.syncOpenNotesToUrl()
|
||||
}
|
||||
|
||||
//Lookup tags set in URL
|
||||
if(to.params.tag && this.$store.getters.totals && this.$store.getters.totals['tags'][to.params.tag]){
|
||||
|
||||
@@ -435,81 +467,121 @@ import { defineAsyncComponent } from 'vue'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openNote(id, event = null){
|
||||
// Where the open-note URL should point. A menu or a tag search owns the
|
||||
// path, so only a plain notes view gets normalised back to /notes.
|
||||
openNotesTargetPath(){
|
||||
|
||||
//
|
||||
// A side menu is open; its close button is a $router.go(-1), so the
|
||||
// path has to stay put or there is nowhere sane to land.
|
||||
if(this.$route.params.openMenu){ return this.$route.path }
|
||||
|
||||
const intId = parseInt(id)
|
||||
if(this.openNotes.includes(intId)){
|
||||
// Preserve a tag search
|
||||
if(this.$route.params.tag){ return this.$route.path }
|
||||
|
||||
console.log('Open already open note?')
|
||||
return '/notes'
|
||||
},
|
||||
// Mirror openNotes into ?open=. This is the ONLY place the open-note
|
||||
// URL is written.
|
||||
syncOpenNotesToUrl(){
|
||||
|
||||
// const openIndex = this.openNotes.indexOf(intId)
|
||||
// if(openIndex != -1){
|
||||
// console.log('Open note and remove it ', intId + ' on index ' + openIndex)
|
||||
// this.openNotes.splice(openIndex, 1)
|
||||
// }
|
||||
// this.$bus.$emit('close_note_by_id', intId)
|
||||
const query = Object.assign({}, this.$route.query)
|
||||
|
||||
if(this.openNotes.length > 0){
|
||||
query.open = this.openNotes.join(',')
|
||||
}
|
||||
else {
|
||||
delete query.open
|
||||
}
|
||||
|
||||
const target = { path: this.openNotesTargetPath(), query }
|
||||
const targetFullPath = this.$router.resolve(target).fullPath
|
||||
|
||||
// Already correct -- don't navigate at all
|
||||
if(targetFullPath === this.$route.fullPath){
|
||||
this.lastSyncedFullPath = targetFullPath
|
||||
return
|
||||
}
|
||||
|
||||
// Set before navigating so the watcher sees it
|
||||
this.lastSyncedFullPath = targetFullPath
|
||||
|
||||
this.$router.replace(target).catch(() => {})
|
||||
},
|
||||
// Tolerant parse of ?open=. Handles 'abc', '1,,2,2', '-4' and the
|
||||
// repeated form ?open=1&open=2 (which arrives as an array).
|
||||
parseOpenQuery(raw){
|
||||
|
||||
if(raw === undefined || raw === null){ return [] }
|
||||
|
||||
// Cap: a hand-crafted URL shouldn't be able to fire 50 note fetches
|
||||
const MAX_RESTORE = 8
|
||||
|
||||
return [].concat(raw)
|
||||
.join(',')
|
||||
.split(',')
|
||||
.map(value => parseInt(value, 10))
|
||||
.filter(value => Number.isInteger(value) && value > 0)
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
.slice(0, MAX_RESTORE)
|
||||
},
|
||||
openNote(id, event = null){
|
||||
|
||||
const intId = parseInt(id, 10)
|
||||
|
||||
// Also absorbs the undefined the '$route.params.id' watcher now gets
|
||||
// when we normalise /notes/open/5 into /notes?open=5
|
||||
if(!Number.isInteger(intId) || intId < 1){ return }
|
||||
|
||||
//Don't open note if a link is clicked in display card
|
||||
if(event && event.target && event.target.nodeName){
|
||||
const nodeClick = event.target.nodeName
|
||||
if(nodeClick == 'A'){ return }
|
||||
}
|
||||
|
||||
// Push note to stack if not open
|
||||
if(Number.isInteger(intId) && !this.openNotes.includes(intId)){
|
||||
this.openNotes.push(intId)
|
||||
if(this.openNotes.includes(intId)){
|
||||
// Already open, but an external push('/notes/open/5') still needs
|
||||
// the URL normalised back to the full set
|
||||
this.syncOpenNotesToUrl()
|
||||
return
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
// change route if open ID is not the same as current ID
|
||||
if(this.$route.params.id != id){
|
||||
console.log('Open note, change route -> route id ' + this.$route.params.id + ' note id ->' + id + ', ' +(this.$route.params.id == id))
|
||||
this.$router.push('/notes/open/'+id)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
return
|
||||
this.openNotes.push(intId)
|
||||
this.syncOpenNotesToUrl()
|
||||
},
|
||||
closeNote(noteId, modified){
|
||||
closeNote(noteId, modified, syncUrl = true){
|
||||
|
||||
console.log('close note', this.$route.fullPath)
|
||||
// parseInt: indexOf is strict, and ids arriving from the URL are
|
||||
// strings. Without this the splice silently misses.
|
||||
const intId = parseInt(noteId, 10)
|
||||
const openIndex = this.openNotes.indexOf(intId)
|
||||
const didClose = (openIndex !== -1)
|
||||
|
||||
const openIndex = this.openNotes.indexOf(noteId)
|
||||
if(openIndex != -1){
|
||||
console.log('Removing note id ', noteId + ' on index ' + openIndex)
|
||||
if(didClose){
|
||||
this.openNotes.splice(openIndex, 1)
|
||||
}
|
||||
|
||||
// //A note has been closed
|
||||
// if(this.$route.fullPath != '/notes'){
|
||||
// this.$router.push('/notes')
|
||||
// }
|
||||
if(this.openNotes.length == 0 && this.$route.fullPath != '/notes'){
|
||||
this.$router.push('/notes')
|
||||
// Only write the URL when the array actually changed. NoteInputPanel
|
||||
// emits close_active_note twice per note (closeButtonAction -> close(),
|
||||
// then beforeUnmount -> close()); the second arrives after the splice.
|
||||
if(didClose && syncUrl){
|
||||
this.syncOpenNotesToUrl()
|
||||
}
|
||||
|
||||
if(modified){
|
||||
console.log('Just closed Note -> ' + noteId + ', modified -> ', modified)
|
||||
this.$store.dispatch('fetchAndUpdateUserTotals')
|
||||
//Focus and animate if modified
|
||||
this.updateSingleNote(noteId, modified)
|
||||
}
|
||||
|
||||
console.log('closeNote(): Open notes length ', this.openNotes.length)
|
||||
},
|
||||
closeAllNotes(){
|
||||
console.log('Close all notes ------------')
|
||||
|
||||
// Backwards, so the in-loop splice can't skip an entry. URL writes are
|
||||
// deferred so this is one navigation rather than N.
|
||||
for (let i = this.openNotes.length - 1; i >= 0; i--) {
|
||||
console.log('Close all notes -> ' + this.openNotes[i])
|
||||
this.closeNote(this.openNotes[i])
|
||||
this.closeNote(this.openNotes[i], false, false)
|
||||
}
|
||||
console.log('----------------')
|
||||
|
||||
this.syncOpenNotesToUrl()
|
||||
},
|
||||
toggleTagFilter(tagId){
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Tailwind 4, wired to coexist with Fomantic UI.
|
||||
*
|
||||
* Deliberately NOT `@import "tailwindcss"` (the whole thing). That pulls in
|
||||
* Preflight as `@layer base`, which zeroes margin/padding/border on every
|
||||
* element, strips heading sizes and list markers, forces img/svg to
|
||||
* display:block and makes buttons and inputs transparent. Dropped on top of
|
||||
* Fomantic it visibly breaks the existing UI.
|
||||
*
|
||||
* Utilities are imported UNLAYERED on purpose. Tailwind's own index.css wraps
|
||||
* them in `@layer utilities`, and an unlayered stylesheet beats any layered
|
||||
* rule regardless of specificity -- since all of Fomantic is unlayered, the
|
||||
* utilities would quietly lose every conflict. Unlayered they compete on normal
|
||||
* specificity instead, and win ties by source order because this file is
|
||||
* imported last in main.js.
|
||||
*
|
||||
* Fomantic mostly uses two-class selectors (.ui.button = specificity 0,2,0), so
|
||||
* it still out-specifies a single utility (0,1,0). To beat Fomantic on a
|
||||
* property it already sets, use the important modifier -- `p-4!`, `bg-red-500!`.
|
||||
*
|
||||
* To try Preflight anyway, add: @import "tailwindcss/preflight.css" layer(base);
|
||||
*/
|
||||
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css";
|
||||
Reference in new issue
Block a user