Compare commits
9 Commits
de3abed6d7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d719b53404 | |||
| f870a12cd5 | |||
| 0b62cd50f3 | |||
| fde4eff12d | |||
| 4f14b39d16 | |||
| 204e89dbce | |||
| 5525a03179 | |||
| 11cae7df36 | |||
| 7be8bec446 |
@@ -34,6 +34,11 @@ make editor # runs `npm ci && npm run build` in editor-build/, rewrites
|
||||
Commit the regenerated `codemirror.bundle.js` and the updated
|
||||
`editor-build/package-lock.json`. `editor-build/node_modules/` is gitignored.
|
||||
|
||||
The bundle is served immutable under a stable filename, so the edit template
|
||||
appends `?v=<content-hash>` to its `<script>` src (`editorBundleVersion` in
|
||||
`main.go`). The hash changes whenever the bundle bytes change, so a rebuilt
|
||||
bundle busts client caches automatically — no manual version bump needed.
|
||||
|
||||
## HTTP API Surface
|
||||
|
||||
| Method | Path | Behaviour |
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
|
||||
{{define "content"}}
|
||||
<script>
|
||||
if (sessionStorage.getItem('editor-wide') === '1') document.body.classList.add('editor-wide');
|
||||
document.body.classList.add('edit-mode');
|
||||
</script>
|
||||
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
||||
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
|
||||
<button type="button" class="btn btn-tool" data-action="redo" title="Redo">↷</button>
|
||||
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">×</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||
<span class="dropdown">
|
||||
@@ -68,13 +72,11 @@
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="wide" data-key="Z" title="Toggle wide mode (Z)">⇔</button>
|
||||
</div>
|
||||
<div id="editor" class="editor-cm"></div>
|
||||
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
|
||||
</form>
|
||||
<script src="/_/editor/vendor/codemirror.bundle.js"></script>
|
||||
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
|
||||
<script src="/_/editor/tables.js"></script>
|
||||
<script src="/_/editor/dates.js"></script>
|
||||
<script src="/_/editor/movie.js"></script>
|
||||
|
||||
+119
-14
@@ -35,6 +35,10 @@
|
||||
CM.drawSelection(),
|
||||
CM.indentOnInput(),
|
||||
CM.EditorView.lineWrapping,
|
||||
// Enable native browser spellcheck on the contenteditable surface
|
||||
// (CM6 leaves it off by default). autocapitalize helps prose entry
|
||||
// on the Android/mobile path; CM's DOM observer absorbs corrections.
|
||||
CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }),
|
||||
CM.markdown({ base: CM.markdownLanguage }),
|
||||
CM.syntaxHighlighting(CM.highlightStyle),
|
||||
CM.closeBrackets(),
|
||||
@@ -142,6 +146,9 @@
|
||||
|
||||
var actions = {
|
||||
save: function () { form.requestSubmit(); },
|
||||
undo: function () { CM.undo(view); view.focus(); },
|
||||
redo: function () { CM.redo(view); view.focus(); },
|
||||
deleteline: function () { CM.deleteLine(view); view.focus(); },
|
||||
bold: function () { wrap('**', '**', 'bold text'); },
|
||||
italic: function () { wrap('*', '*', 'italic text'); },
|
||||
h1: function () { linePrefix('# '); },
|
||||
@@ -167,24 +174,78 @@
|
||||
dateiso: function () { insertAtCursor(D.isoDate()); },
|
||||
datelong: function () { insertAtCursor(D.longDate()); },
|
||||
movie: function () { M.run(movieCtx); },
|
||||
wide: function () {
|
||||
var enabled = !document.body.classList.contains('editor-wide');
|
||||
document.body.classList.toggle('editor-wide', enabled);
|
||||
sessionStorage.setItem('editor-wide', enabled ? '1' : '0');
|
||||
},
|
||||
};
|
||||
|
||||
// Wiki link button: drop an empty [[]] at the cursor and open the `[[`
|
||||
// completion popup so the same autocomplete flow handles target selection.
|
||||
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
|
||||
// path, no empty/dot segments. Used to gate the modal's INSERT button.
|
||||
function isValidWikiTarget(p) {
|
||||
if (!p || p[0] !== '/') return false;
|
||||
var trimmed = p.replace(/^\/+|\/+$/g, '');
|
||||
if (trimmed === '') return true;
|
||||
var segs = trimmed.split('/');
|
||||
for (var i = 0; i < segs.length; i++) {
|
||||
if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wiki link button (ALT+SHIFT+P): open a modal with a target field backed by
|
||||
// full /_search typeahead plus an optional display-text field, then insert
|
||||
// [[target]] or [[target::display]] at the cursor. (Inline `[[` typing uses
|
||||
// the folder-scoped completion in wikicomplete.js instead.)
|
||||
function insertWikilink() {
|
||||
var sel = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: '[[]]' },
|
||||
selection: { anchor: sel.from + 2 },
|
||||
scrollIntoView: true,
|
||||
var selectedText = view.state.sliceDoc(sel.from, sel.to);
|
||||
|
||||
var container = document.createElement('div');
|
||||
|
||||
var targetWrap = document.createElement('div');
|
||||
var targetInput = document.createElement('input');
|
||||
targetInput.type = 'text';
|
||||
targetInput.className = 'input';
|
||||
targetInput.placeholder = 'Page path or search…';
|
||||
targetWrap.appendChild(targetInput);
|
||||
|
||||
var displayInput = document.createElement('input');
|
||||
displayInput.type = 'text';
|
||||
displayInput.className = 'input';
|
||||
displayInput.placeholder = 'Display text (optional)';
|
||||
if (selectedText) displayInput.value = selectedText;
|
||||
|
||||
container.appendChild(targetWrap);
|
||||
container.appendChild(displayInput);
|
||||
|
||||
var handle = openModal({
|
||||
title: 'Insert link',
|
||||
body: container,
|
||||
confirm: {
|
||||
label: 'INSERT',
|
||||
initiallyDisabled: true,
|
||||
onConfirm: function () {
|
||||
var target = targetInput.value.trim();
|
||||
if (!isValidWikiTarget(target)) return;
|
||||
var display = displayInput.value.trim();
|
||||
handle.close();
|
||||
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateConfirm() {
|
||||
handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim()));
|
||||
}
|
||||
targetInput.addEventListener('input', updateConfirm);
|
||||
|
||||
window.attachSuggestions(targetInput, {
|
||||
showFooter: false,
|
||||
container: targetWrap,
|
||||
onPick: function (r) {
|
||||
targetInput.value = '/' + r.path;
|
||||
updateConfirm();
|
||||
displayInput.focus();
|
||||
displayInput.select();
|
||||
}
|
||||
});
|
||||
view.focus();
|
||||
CM.startCompletion(view);
|
||||
}
|
||||
|
||||
// --- Keyboard shortcut registration ---
|
||||
@@ -200,6 +261,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the editor focused when a toolbar button is tapped. Without this the
|
||||
// button steals focus on mousedown, which dismisses the mobile soft keyboard
|
||||
// before the action runs (and view.focus() can't reopen it without a direct
|
||||
// gesture). preventDefault on mousedown blocks the focus shift; click still
|
||||
// fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes
|
||||
// dropdown toggles, which also must not pull focus off the editor.
|
||||
var toolbar = document.querySelector('.editor-toolbar');
|
||||
if (toolbar) {
|
||||
toolbar.addEventListener('mousedown', function (e) {
|
||||
if (e.target.closest('.btn')) e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!e.altKey || !e.shiftKey) return;
|
||||
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
||||
@@ -214,5 +288,36 @@
|
||||
|
||||
// --- Dropdowns ---
|
||||
|
||||
document.querySelectorAll('.dropdown-toggle').forEach(wireDropdown);
|
||||
// The toolbar scrolls horizontally (so it clips its absolutely-positioned
|
||||
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
|
||||
// menu to the viewport so it escapes the clip, opening upward when there
|
||||
// isn't room below it (the bottom-toolbar case).
|
||||
function pinMenu(toggle, menu) {
|
||||
if (!menu.classList.contains('is-open')) return;
|
||||
var r = toggle.getBoundingClientRect();
|
||||
var vh = window.innerHeight;
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.overflowY = 'auto';
|
||||
var spaceBelow = vh - r.bottom;
|
||||
var spaceAbove = r.top;
|
||||
if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) {
|
||||
menu.style.top = 'auto';
|
||||
menu.style.bottom = (vh - r.top) + 'px';
|
||||
menu.style.maxHeight = (spaceAbove - 8) + 'px';
|
||||
} else {
|
||||
menu.style.bottom = 'auto';
|
||||
menu.style.top = r.bottom + 'px';
|
||||
menu.style.maxHeight = (spaceBelow - 8) + 'px';
|
||||
}
|
||||
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
|
||||
menu.style.left = Math.max(4, left) + 'px';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.dropdown-toggle').forEach(function (toggle) {
|
||||
wireDropdown(toggle);
|
||||
var menu = toggle.parentElement.querySelector('.dropdown-menu');
|
||||
if (!menu || !toolbar || !toolbar.contains(toggle)) return;
|
||||
// Runs after wireDropdown's own click handler has toggled is-open.
|
||||
toggle.addEventListener('click', function () { pinMenu(toggle, menu); });
|
||||
});
|
||||
})();
|
||||
|
||||
+14
-14
File diff suppressed because one or more lines are too long
@@ -1,93 +1,115 @@
|
||||
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
|
||||
//
|
||||
// Triggers when the cursor sits in a `[[…` token (freshly typed or re-edited
|
||||
// inside an existing `[[…]]`), queries the existing /_search JSON endpoint
|
||||
// (debounced), and offers matching page paths. Selecting a result inserts the
|
||||
// absolute path and auto-closes `]]`, leaving the cursor before the close so a
|
||||
// `::display` alias can be typed (decision 7). Exposes window.WikiComplete.source
|
||||
// A level-by-level folder/file browser scoped to the path typed so far. It
|
||||
// fires only once the `[[` token's content begins with `/` (targets are
|
||||
// absolute; free-text search lives in the toolbar modal instead). The content
|
||||
// is split into a parent path (up to and including the last `/`) and a partial
|
||||
// segment (the text after it); the parent's children are fetched from the
|
||||
// existing `?tree=1` endpoint and filtered to names containing the partial
|
||||
// (case-insensitive substring).
|
||||
//
|
||||
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level
|
||||
// deeper; picking a file inserts `<name>` and stops. Only the current segment
|
||||
// is replaced, so the trailing `]]` stays put and the cursor parks before it,
|
||||
// leaving room to type a `::display` alias. Exposes window.WikiComplete.source
|
||||
// for main.js to register via CM's autocompletion().
|
||||
window.WikiComplete = (function () {
|
||||
var DEBOUNCE_MS = 100;
|
||||
var MIN_QUERY_LEN = 2;
|
||||
|
||||
// Mirrors the Go validator (wikilinks.go isValidWikiTarget) and the old
|
||||
// client check: absolute path, no empty/'.'/'..' segments. Server results
|
||||
// are wiki paths so this is mostly a guard against odd index entries.
|
||||
function isValidWikiTarget(p) {
|
||||
if (!p || p[0] !== '/') return false;
|
||||
var trimmed = p.replace(/^\/+|\/+$/g, '');
|
||||
if (trimmed === '') return true;
|
||||
var segs = trimmed.split('/');
|
||||
for (var i = 0; i < segs.length; i++) {
|
||||
if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
|
||||
}
|
||||
return true;
|
||||
// treeURL builds the `?tree=1` request URL for an absolute parent path,
|
||||
// percent-encoding each segment. A leading/trailing slash is tolerated;
|
||||
// root resolves to `/?tree=1`.
|
||||
function treeURL(parent) {
|
||||
var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
if (trimmed === '') return '/?tree=1';
|
||||
var enc = trimmed.split('/').map(encodeURIComponent).join('/');
|
||||
return '/' + enc + '/?tree=1';
|
||||
}
|
||||
|
||||
// Build the apply() for a chosen result. Inserts the path over the typed
|
||||
// query and ensures a single trailing `]]`, cursor parked before it. If the
|
||||
// user (or closeBrackets) already produced `]]`, we don't double it up.
|
||||
function makeApply(path) {
|
||||
return function (view, completion, from, to) {
|
||||
var closed = view.state.sliceDoc(to, to + 2) === ']]';
|
||||
var insert = closed ? path : path + ']]';
|
||||
view.dispatch({
|
||||
changes: { from: from, to: to, insert: insert },
|
||||
selection: { anchor: from + path.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function fetchSuggest(query) {
|
||||
return fetch('/_search?q=' + encodeURIComponent(query), {
|
||||
function fetchTree(parent) {
|
||||
return fetch(treeURL(parent), {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
}).then(function (r) {
|
||||
// A 404 means the parent folder doesn't exist (typo, or a path under
|
||||
// a file) — treat it as "no completions", not an error.
|
||||
if (r.status === 404) return null;
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
// CM completion source. `[[` plus any run of non-`]`, non-newline chars
|
||||
// before the cursor is the trigger; the query is the text after `[[`. The
|
||||
// replaced range extends past the cursor to the end of the inner token (up
|
||||
// to `]]`/EOL) so re-editing inside an existing `[[…]]` replaces the whole
|
||||
// target instead of duplicating the trailing text.
|
||||
// makeApply builds the apply() for a chosen entry. It replaces only the
|
||||
// current segment ([from, to]); the trailing `]]` is untouched, so the
|
||||
// cursor ends up parked before it. Folders append `/` and re-open the popup
|
||||
// to drill into the next level; files terminate.
|
||||
function makeApply(name, kind) {
|
||||
return function (view, completion, from, to) {
|
||||
var isFolder = kind === 'folder';
|
||||
var insert = isFolder ? name + '/' : name;
|
||||
view.dispatch({
|
||||
changes: { from: from, to: to, insert: insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
if (isFolder) {
|
||||
// Re-open after the transaction so the completion plugin sees
|
||||
// the updated document (the next level's parent path).
|
||||
setTimeout(function () { CM.startCompletion(view); }, 0);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CM completion source. Activates when `[[` is followed by content that
|
||||
// begins with `/`. The content is split at its last `/` into a parent path
|
||||
// and a partial segment; the parent's children are fetched, filtered to
|
||||
// names containing the partial (case-insensitive substring), and offered
|
||||
// with name-only labels.
|
||||
function source(context) {
|
||||
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
||||
if (!match) return null;
|
||||
var query = context.state.sliceDoc(match.from + 2, context.pos);
|
||||
if (query.length < MIN_QUERY_LEN && !context.explicit) return null;
|
||||
var content = context.state.sliceDoc(match.from + 2, context.pos);
|
||||
if (content[0] !== '/') return null;
|
||||
|
||||
var lastSlash = content.lastIndexOf('/');
|
||||
var parent = content.slice(0, lastSlash + 1);
|
||||
var partial = content.slice(lastSlash + 1);
|
||||
|
||||
// Replace only the current segment: from the start of the partial up to
|
||||
// the next `/` or `]` (or end of line). This narrows re-edits inside an
|
||||
// existing `[[…]]` so drilling doesn't duplicate trailing text.
|
||||
var from = match.from + 2 + lastSlash + 1;
|
||||
var line = context.state.doc.lineAt(context.pos);
|
||||
var to = context.pos;
|
||||
while (to < line.to && context.state.sliceDoc(to, to + 1) !== ']') to++;
|
||||
while (to < line.to) {
|
||||
var ch = context.state.sliceDoc(to, to + 1);
|
||||
if (ch === '/' || ch === ']') break;
|
||||
to++;
|
||||
}
|
||||
|
||||
return new Promise(function (resolve) {
|
||||
var timer = setTimeout(function () {
|
||||
if (context.aborted) { resolve(null); return; }
|
||||
fetchSuggest(query).then(function (resp) {
|
||||
if (context.aborted) { resolve(null); return; }
|
||||
var options = (resp.results || []).reduce(function (acc, r) {
|
||||
var path = '/' + r.path;
|
||||
if (isValidWikiTarget(path)) {
|
||||
acc.push({ label: path, detail: r.name, apply: makeApply(path) });
|
||||
}
|
||||
if (context.aborted) { resolve(null); return; }
|
||||
fetchTree(parent).then(function (resp) {
|
||||
if (context.aborted || !resp) { resolve(null); return; }
|
||||
var needle = partial.toLowerCase();
|
||||
var options = (resp.entries || []).reduce(function (acc, e) {
|
||||
if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
|
||||
return acc;
|
||||
}, []);
|
||||
resolve({ from: match.from + 2, to: to, options: options });
|
||||
}).catch(function () {
|
||||
resolve(null);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
// CM doesn't cancel pending promises, but it sets context.aborted;
|
||||
// clear the timer too if the doc moved on before it fired.
|
||||
if (context.addEventListener) {
|
||||
context.addEventListener('abort', function () { clearTimeout(timer); });
|
||||
}
|
||||
}
|
||||
acc.push({
|
||||
label: e.name,
|
||||
type: e.kind === 'folder' ? 'folder' : 'file',
|
||||
apply: makeApply(e.name, e.kind),
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
// No validFor: the source re-runs on each keystroke, so every
|
||||
// edit (more chars, backspace, or a `/` that drills into the
|
||||
// next folder) re-fetches and re-filters from scratch.
|
||||
resolve({ from: from, to: to, options: options });
|
||||
}).catch(function () {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { source: source, isValidWikiTarget: isValidWikiTarget };
|
||||
return { source: source };
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Fitness dashboard range dropdowns: changing one reloads the page with that
|
||||
// chart's query parameter updated. Plain GET navigation — each range is a
|
||||
// distinct, bookmarkable view, so no postReplace/history handling is needed.
|
||||
document.addEventListener('change', function (e) {
|
||||
var sel = e.target.closest('[data-fitness-range]');
|
||||
if (!sel) return;
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "fitnessChart"}}
|
||||
<section class="fitness-chart panel">
|
||||
<div class="fitness-chart-header row space-between">
|
||||
<span class="caption">{{.Title}}</span>
|
||||
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
|
||||
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
{{if .Empty}}
|
||||
<p class="fitness-empty is-empty">No data in this range.</p>
|
||||
{{else}}
|
||||
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
|
||||
{{range .YTicks}}
|
||||
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
|
||||
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
|
||||
{{end}}
|
||||
{{range .XTicks}}
|
||||
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
|
||||
{{end}}
|
||||
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
|
||||
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotB}}" x2="{{.PlotR}}" y2="{{.PlotB}}"/>
|
||||
{{range .Lines}}
|
||||
<polyline class="chart-line" points="{{.}}"/>
|
||||
{{end}}
|
||||
{{range .Dots}}
|
||||
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
|
||||
{{end}}
|
||||
{{if .Goal}}
|
||||
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
|
||||
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
|
||||
{{end}}
|
||||
</svg>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
<div class="fitness-dash col">
|
||||
{{if .Notice}}
|
||||
<p class="muted">{{.Notice}}</p>
|
||||
{{else}}
|
||||
{{range .Charts}}{{template "fitnessChart" .}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<script src="/_/fitness/fitness.js"></script>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="icon" href="/_/favicon.ico" />
|
||||
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
||||
|
||||
@@ -17,7 +17,7 @@ function openViewSettings() {
|
||||
options.forEach(function (opt) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn btn-small';
|
||||
b.className = 'btn';
|
||||
b.textContent = opt.label;
|
||||
if (state[key] === opt.value) b.classList.add('is-active');
|
||||
b.addEventListener('click', function () {
|
||||
|
||||
+69
-4
@@ -70,6 +70,7 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-3 { gap: var(--space-3); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
.space-between { justify-content: space-between; }
|
||||
.divider-dashed { border-bottom: var(--border-dashed); }
|
||||
|
||||
/* === Page layout ===
|
||||
@@ -166,7 +167,7 @@ footer {
|
||||
.btn::before { content: "["; color: var(--secondary); }
|
||||
.btn::after { content: "]"; color: var(--secondary); }
|
||||
.btn:hover { color: var(--primary-hover); }
|
||||
.btn-small { font-size: 0.65rem; font-weight: normal; vertical-align: middle; }
|
||||
.btn-small { font-size: 0.8rem; font-weight: normal; vertical-align: middle; }
|
||||
.btn-tool { padding: 0 0.15rem; }
|
||||
.btn-block {
|
||||
display: flex;
|
||||
@@ -352,16 +353,24 @@ main > h2 {
|
||||
.suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; }
|
||||
.suggest-footer > td { color: var(--link); font-size: var(--font-sm); }
|
||||
|
||||
/* === Editor toolbar === */
|
||||
/* === Editor toolbar ===
|
||||
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
|
||||
than breaking into stacked rows. A horizontal-scroll container also clips
|
||||
overflow-y, so open dropdown menus are pinned to the viewport via JS
|
||||
(editor/main.js pinMenu) to escape the clip. */
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--space-1);
|
||||
border: var(--border);
|
||||
border-bottom: none;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-panel-hover);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.editor-toolbar::-webkit-scrollbar { display: none; }
|
||||
.editor-toolbar > * { flex-shrink: 0; }
|
||||
.toolbar-sep {
|
||||
width: 1px;
|
||||
background: var(--secondary);
|
||||
@@ -371,7 +380,9 @@ main > h2 {
|
||||
|
||||
/* === Edit form === */
|
||||
.edit-form { display: flex; flex-direction: column; }
|
||||
body.editor-wide .page-wrap { max-width: none; }
|
||||
/* The sidebar is always empty while editing, so the editor uses the full
|
||||
viewport: drop the reserved 14rem sidebar track and the centered max-width. */
|
||||
body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: none; }
|
||||
/* CodeMirror mount. The .cm-editor visual treatment (border, bg, font, padding)
|
||||
lives in the CM theme (editor-build/entry.js), keyed off the same :root
|
||||
variables; this only sizes the container. */
|
||||
@@ -644,6 +655,28 @@ aside.sidebar:empty { display: none; }
|
||||
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
|
||||
.btn-block.cal-current { color: var(--primary-hover); }
|
||||
|
||||
/* === Fitness dashboard ===
|
||||
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
|
||||
colors and strokes are applied here via classes so the inline SVG follows
|
||||
the theme palette. */
|
||||
.fitness-chart { padding: var(--space-3); }
|
||||
.fitness-chart-header { margin-bottom: var(--space-2); }
|
||||
.fitness-range { width: auto; font-size: var(--font-sm); }
|
||||
.fitness-empty {
|
||||
border: var(--border-dashed);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.fitness-svg { display: block; width: 100%; height: auto; }
|
||||
.fitness-svg .chart-grid { stroke: var(--bg-panel-hover); }
|
||||
.fitness-svg .chart-axis { stroke: var(--text-muted); }
|
||||
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
|
||||
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
|
||||
.fitness-svg .chart-dot { fill: var(--link); }
|
||||
.fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; }
|
||||
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 1100px) {
|
||||
.page-wrap { grid-template-columns: 1fr; }
|
||||
@@ -675,6 +708,38 @@ aside.sidebar:empty { display: none; }
|
||||
main { padding: var(--space-4) var(--space-3); }
|
||||
.editor-cm { min-height: 50vh; }
|
||||
.sidebar { width: calc(100% - 1.5rem); }
|
||||
/* Editing on mobile is full-bleed: drop the page/main inset so the toolbar
|
||||
and editor use the entire viewport width. */
|
||||
body.edit-mode .page-wrap { padding: 0; gap: 0; }
|
||||
body.edit-mode main { padding: 0; }
|
||||
/* Fingers, not cursors: give every toolbar control a ~44px tap target. */
|
||||
.editor-toolbar { gap: var(--space-2); padding: var(--space-2); }
|
||||
.editor-toolbar .btn-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
padding: 0 var(--space-1);
|
||||
}
|
||||
/* Pin the toolbar above the on-screen keyboard rather than at the top, which
|
||||
is out of thumb reach while typing. interactive-widget=resizes-content
|
||||
(layout.html viewport) shrinks the viewport on keyboard open so bottom: 0
|
||||
sits directly above it. cm-content reserves matching scroll space so the
|
||||
last lines aren't hidden behind the bar. */
|
||||
body.edit-mode .editor-toolbar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
border: none;
|
||||
border-top: var(--border);
|
||||
padding-bottom: calc(var(--space-2) + env(safe-area-inset-bottom));
|
||||
}
|
||||
body.edit-mode .cm-content {
|
||||
padding-bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
.modal-backdrop { padding: var(--space-2); align-items: flex-start; }
|
||||
.modal { max-width: none; margin-top: var(--space-4); }
|
||||
.modal .panel-header { cursor: default; }
|
||||
|
||||
@@ -159,7 +159,7 @@ func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok b
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage {
|
||||
func (d *diaryHandler) handle(root, fsPath, urlPath string, _ *http.Request) *specialPage {
|
||||
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -544,7 +544,6 @@ func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntr
|
||||
return weeks
|
||||
}
|
||||
|
||||
|
||||
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
||||
type diaryPhoto struct {
|
||||
Date time.Time
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// :root CSS variables so there are no hardcoded colors/spacing here.
|
||||
import { EditorState, EditorSelection, Compartment, Prec } from "@codemirror/state";
|
||||
import { EditorView, keymap, drawSelection } from "@codemirror/view";
|
||||
import { history, historyKeymap, defaultKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands";
|
||||
import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
|
||||
import { syntaxHighlighting, HighlightStyle, indentOnInput } from "@codemirror/language";
|
||||
import {
|
||||
@@ -89,6 +89,9 @@ window.CM = {
|
||||
historyKeymap,
|
||||
defaultKeymap,
|
||||
indentWithTab,
|
||||
undo,
|
||||
redo,
|
||||
deleteLine,
|
||||
markdown,
|
||||
markdownLanguage,
|
||||
markdownKeymap,
|
||||
|
||||
+504
@@ -0,0 +1,504 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
|
||||
}
|
||||
|
||||
// waistlineExportFile is the exact filename the user copies into the folder.
|
||||
// The single file is always the latest export — no glob, no multi-file merge.
|
||||
const waistlineExportFile = "waistline_export.json"
|
||||
|
||||
type fitnessHandler struct{}
|
||||
|
||||
// redirect: the fitness dashboard has no virtual URLs; everything renders
|
||||
// inside the normal GET /{path}/ flow.
|
||||
func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// handle renders the dashboard for folders whose .page-settings declares
|
||||
// type = fitness. Markdown content and the folder listing stay visible so
|
||||
// the user can verify an uploaded export arrived.
|
||||
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
|
||||
s := readPageSettings(fsPath)
|
||||
if s == nil || s.Type != "fitness" {
|
||||
return nil
|
||||
}
|
||||
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
|
||||
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
|
||||
return &specialPage{
|
||||
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
|
||||
SuppressTOC: true,
|
||||
}
|
||||
}
|
||||
|
||||
// === Time ranges ===
|
||||
|
||||
type fitnessRange struct {
|
||||
Value string
|
||||
Label string
|
||||
Months int // 0 = all data
|
||||
}
|
||||
|
||||
var fitnessRanges = []fitnessRange{
|
||||
{"1m", "1 month", 1},
|
||||
{"3m", "3 months", 3},
|
||||
{"1y", "1 year", 12},
|
||||
{"all", "All", 0},
|
||||
}
|
||||
|
||||
func validFitnessRange(v, fallback string) string {
|
||||
for _, r := range fitnessRanges {
|
||||
if r.Value == v {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func rangeMonths(v string) int {
|
||||
for _, r := range fitnessRanges {
|
||||
if r.Value == v {
|
||||
return r.Months
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// === Waistline export parsing ===
|
||||
|
||||
// wlNum is a number in a Waistline export: values appear as JSON numbers,
|
||||
// numeric strings, null, or are absent. Unparsable values read as not-ok
|
||||
// instead of failing the whole export parse.
|
||||
type wlNum struct {
|
||||
val float64
|
||||
ok bool
|
||||
}
|
||||
|
||||
func (n *wlNum) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "" || s == "null" {
|
||||
return nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
n.val = v
|
||||
n.ok = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calorie tracking was removed pending a rethink of the (undocumented)
|
||||
// per-item formula; only the weight series is read from the export.
|
||||
type wlExport struct {
|
||||
Diary []wlDiaryEntry `json:"diary"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
}
|
||||
|
||||
type wlDiaryEntry struct {
|
||||
DateTime string `json:"dateTime"`
|
||||
Stats struct {
|
||||
Weight wlNum `json:"weight"`
|
||||
} `json:"stats"`
|
||||
}
|
||||
|
||||
// exportDate extracts the calendar day from a Waistline dateTime. The values
|
||||
// are UTC-midnight timestamps; taking the first 10 characters avoids time
|
||||
// zone conversions shifting the day.
|
||||
func exportDate(s string) (time.Time, bool) {
|
||||
if len(s) < 10 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s[:10])
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
|
||||
// weekday slot of the shared goal. Best-effort: any missing or unparsable
|
||||
// level means "no goal line", never an error.
|
||||
func goalValue(settings json.RawMessage, key string) (float64, bool) {
|
||||
var s struct {
|
||||
Goals map[string]json.RawMessage `json:"goals"`
|
||||
}
|
||||
if json.Unmarshal(settings, &s) != nil {
|
||||
return 0, false
|
||||
}
|
||||
var g struct {
|
||||
GoalList []struct {
|
||||
Goal []wlNum `json:"goal"`
|
||||
} `json:"goal-list"`
|
||||
}
|
||||
if json.Unmarshal(s.Goals[key], &g) != nil {
|
||||
return 0, false
|
||||
}
|
||||
if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
n := g.GoalList[0].Goal[0]
|
||||
return n.val, n.ok
|
||||
}
|
||||
|
||||
func exportWeightUnit(settings json.RawMessage) string {
|
||||
var s struct {
|
||||
Units struct {
|
||||
Weight string `json:"weight"`
|
||||
} `json:"units"`
|
||||
}
|
||||
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
|
||||
return s.Units.Weight
|
||||
}
|
||||
return "kg"
|
||||
}
|
||||
|
||||
// === Series extraction ===
|
||||
|
||||
type weightPoint struct {
|
||||
date time.Time
|
||||
value float64
|
||||
days int // >0: weekly mean over this many measured days
|
||||
}
|
||||
|
||||
// extractWeights computes the weight series from the export: one point per
|
||||
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
|
||||
// one wins.
|
||||
func extractWeights(ex *wlExport) []weightPoint {
|
||||
byDate := map[time.Time]float64{}
|
||||
for _, e := range ex.Diary {
|
||||
day, ok := exportDate(e.DateTime)
|
||||
if !ok || !e.Stats.Weight.ok {
|
||||
continue
|
||||
}
|
||||
byDate[day] = e.Stats.Weight.val
|
||||
}
|
||||
weights := make([]weightPoint, 0, len(byDate))
|
||||
for d, v := range byDate {
|
||||
weights = append(weights, weightPoint{date: d, value: v})
|
||||
}
|
||||
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
||||
return weights
|
||||
}
|
||||
|
||||
func mondayOf(t time.Time) time.Time {
|
||||
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
|
||||
}
|
||||
|
||||
// weeklyMeanWeights buckets the daily weight series into ISO weeks (Monday
|
||||
// start) and averages over the days that have a measurement. Weeks without
|
||||
// any measurement produce no point.
|
||||
func weeklyMeanWeights(points []weightPoint) []weightPoint {
|
||||
type acc struct {
|
||||
sum float64
|
||||
n int
|
||||
}
|
||||
byWeek := map[time.Time]*acc{}
|
||||
for _, p := range points {
|
||||
w := mondayOf(p.date)
|
||||
a := byWeek[w]
|
||||
if a == nil {
|
||||
a = &acc{}
|
||||
byWeek[w] = a
|
||||
}
|
||||
a.sum += p.value
|
||||
a.n++
|
||||
}
|
||||
out := make([]weightPoint, 0, len(byWeek))
|
||||
for w, a := range byWeek {
|
||||
out = append(out, weightPoint{date: w, value: a.sum / float64(a.n), days: a.n})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].date.Before(out[j].date) })
|
||||
return out
|
||||
}
|
||||
|
||||
// === SVG geometry ===
|
||||
|
||||
// Chart canvas in viewBox units. The SVG scales to container width via
|
||||
// viewBox + width:100%, so these only set proportions and text size.
|
||||
const (
|
||||
chartW = 560.0
|
||||
chartH = 240.0
|
||||
chartLeft = 46.0
|
||||
chartRight = 8.0
|
||||
chartTop = 10.0
|
||||
chartBottom = 24.0
|
||||
)
|
||||
|
||||
// svgNum formats a coordinate or display value: rounded to 2 decimals,
|
||||
// trailing zeros trimmed.
|
||||
func svgNum(v float64) string {
|
||||
return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
|
||||
}
|
||||
|
||||
type chartScale struct {
|
||||
x0, x1 time.Time
|
||||
y0, y1 float64
|
||||
}
|
||||
|
||||
func (s chartScale) x(t time.Time) float64 {
|
||||
span := s.x1.Sub(s.x0).Seconds()
|
||||
if span <= 0 {
|
||||
return chartLeft
|
||||
}
|
||||
return chartLeft + (chartW-chartLeft-chartRight)*t.Sub(s.x0).Seconds()/span
|
||||
}
|
||||
|
||||
func (s chartScale) y(v float64) float64 {
|
||||
span := s.y1 - s.y0
|
||||
if span <= 0 {
|
||||
return chartH - chartBottom
|
||||
}
|
||||
return chartH - chartBottom - (chartH-chartBottom-chartTop)*(v-s.y0)/span
|
||||
}
|
||||
|
||||
// === View models ===
|
||||
|
||||
type fitnessOptVM struct {
|
||||
Value, Label string
|
||||
Selected bool
|
||||
}
|
||||
|
||||
type fitnessTickVM struct {
|
||||
Pos, Label, Anchor string
|
||||
}
|
||||
|
||||
type fitnessDotVM struct {
|
||||
X, Y, Title string
|
||||
}
|
||||
|
||||
type fitnessGoalVM struct {
|
||||
Y, LabelY, Label string
|
||||
}
|
||||
|
||||
type fitnessChartVM struct {
|
||||
Title string
|
||||
Param string
|
||||
Options []fitnessOptVM
|
||||
Empty bool
|
||||
|
||||
ViewW, ViewH string
|
||||
PlotX, PlotY, PlotR, PlotB string
|
||||
YLabelX, XLabelY string
|
||||
YTicks, XTicks []fitnessTickVM
|
||||
Goal *fitnessGoalVM
|
||||
Lines []string // polyline points attributes
|
||||
Dots []fitnessDotVM
|
||||
}
|
||||
|
||||
type fitnessDashVM struct {
|
||||
Notice string
|
||||
Charts []fitnessChartVM
|
||||
}
|
||||
|
||||
func newChartVM(title, param, sel string) fitnessChartVM {
|
||||
opts := make([]fitnessOptVM, len(fitnessRanges))
|
||||
for i, r := range fitnessRanges {
|
||||
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
|
||||
}
|
||||
return fitnessChartVM{
|
||||
Title: title, Param: param, Options: opts,
|
||||
ViewW: svgNum(chartW), ViewH: svgNum(chartH),
|
||||
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
|
||||
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
|
||||
YLabelX: svgNum(chartLeft - 5), XLabelY: svgNum(chartH - chartBottom + 14),
|
||||
}
|
||||
}
|
||||
|
||||
// yTickVMs places gridlines at fixed multiples of step across the y domain
|
||||
// (always 5 kg guides on weight charts, regardless of range).
|
||||
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
|
||||
var out []fitnessTickVM
|
||||
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
|
||||
out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
|
||||
gy := sc.y(goal)
|
||||
ly := gy - 4
|
||||
if ly < chartTop+10 {
|
||||
ly = gy + 12
|
||||
}
|
||||
vm.Goal = &fitnessGoalVM{Y: svgNum(gy), LabelY: svgNum(ly), Label: label}
|
||||
}
|
||||
|
||||
// === Chart builders ===
|
||||
|
||||
// buildWeightChart renders a weight line chart. It serves both the per-day
|
||||
// series and the weekly-mean series (points carrying days > 0); the line is
|
||||
// drawn continuous across days/weeks without a measurement.
|
||||
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
|
||||
vm := newChartVM(title, param, sel)
|
||||
|
||||
points := all
|
||||
var x0, x1 time.Time
|
||||
if m := rangeMonths(sel); m > 0 {
|
||||
x0 = today.AddDate(0, -m, 0)
|
||||
points = filterWeights(all, x0)
|
||||
if len(points) == 0 {
|
||||
vm.Empty = true
|
||||
return vm
|
||||
}
|
||||
x1 = today
|
||||
if last := points[len(points)-1].date; last.After(x1) {
|
||||
x1 = last
|
||||
}
|
||||
} else {
|
||||
if len(points) == 0 {
|
||||
vm.Empty = true
|
||||
return vm
|
||||
}
|
||||
x0 = points[0].date
|
||||
x1 = points[len(points)-1].date
|
||||
}
|
||||
// Degenerate domain (single point on All): widen so the dot sits inside
|
||||
// the plot instead of on its edge.
|
||||
if !x0.Before(x1) {
|
||||
x0 = x0.AddDate(0, 0, -1)
|
||||
x1 = x1.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
lo, hi := points[0].value, points[0].value
|
||||
for _, p := range points {
|
||||
lo = min(lo, p.value)
|
||||
hi = max(hi, p.value)
|
||||
}
|
||||
if hasGoal {
|
||||
lo = min(lo, goal)
|
||||
hi = max(hi, goal)
|
||||
}
|
||||
pad := (hi - lo) * 0.05
|
||||
if pad == 0 {
|
||||
pad = 1
|
||||
}
|
||||
sc := chartScale{x0, x1, lo - pad, hi + pad}
|
||||
|
||||
vm.YTicks = yTickVMs(sc, 5)
|
||||
vm.XTicks = timeXTicks(sc, 4)
|
||||
|
||||
// One continuous polyline through every point in range — days without a
|
||||
// measurement do not break the line. Point markers carry the hover
|
||||
// <title>; on dense ranges they are dropped and the bare line stays
|
||||
// legible. A single point in range renders as a dot.
|
||||
dot := func(p weightPoint) fitnessDotVM {
|
||||
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
|
||||
if p.days > 0 {
|
||||
label = fmt.Sprintf("Week of %s: %s %s (%d days)",
|
||||
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
|
||||
}
|
||||
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
|
||||
}
|
||||
if len(points) >= 2 {
|
||||
var b strings.Builder
|
||||
for i, p := range points {
|
||||
if i > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(svgNum(sc.x(p.date)))
|
||||
b.WriteByte(',')
|
||||
b.WriteString(svgNum(sc.y(p.value)))
|
||||
}
|
||||
vm.Lines = append(vm.Lines, b.String())
|
||||
}
|
||||
if len(points) <= 100 {
|
||||
for _, p := range points {
|
||||
vm.Dots = append(vm.Dots, dot(p))
|
||||
}
|
||||
}
|
||||
|
||||
if hasGoal {
|
||||
vm.setGoal(sc, goal, "goal "+svgNum(goal))
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
func filterWeights(points []weightPoint, from time.Time) []weightPoint {
|
||||
var out []weightPoint
|
||||
for _, p := range points {
|
||||
if !p.date.Before(from) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// timeXTicks places n+1 evenly spaced date labels across a continuous time
|
||||
// axis; the last label is end-anchored so it stays inside the viewBox.
|
||||
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
|
||||
span := sc.x1.Sub(sc.x0)
|
||||
var out []fitnessTickVM
|
||||
prev := ""
|
||||
for i := 0; i <= n; i++ {
|
||||
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
|
||||
label := t.Format("2006-01-02")
|
||||
if label == prev {
|
||||
continue
|
||||
}
|
||||
prev = label
|
||||
anchor := "middle"
|
||||
if i == n {
|
||||
anchor = "end"
|
||||
}
|
||||
out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// === Rendering ===
|
||||
|
||||
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
|
||||
|
||||
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
|
||||
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
|
||||
var buf bytes.Buffer
|
||||
if err := fitnessTmpl.Execute(&buf, data); err != nil {
|
||||
log.Printf("fitness template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// buildFitnessDash reads and parses the export per request — no caching, no
|
||||
// indexes. A read or parse failure (including a truncated mid-upload file)
|
||||
// becomes an inline notice; the page itself always renders.
|
||||
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
|
||||
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
|
||||
if err != nil {
|
||||
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
|
||||
}
|
||||
var ex wlExport
|
||||
if err := json.Unmarshal(raw, &ex); err != nil {
|
||||
return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()}
|
||||
}
|
||||
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
weights := extractWeights(&ex)
|
||||
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
|
||||
unit := exportWeightUnit(ex.Settings)
|
||||
|
||||
return fitnessDashVM{Charts: []fitnessChartVM{
|
||||
buildWeightChart(weights, wGoal, hasWGoal, weightSel,
|
||||
"weight", "Weight ("+unit+")", unit, today),
|
||||
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
|
||||
"weekly", "Weekly average weight ("+unit+")", unit, today),
|
||||
}}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
@@ -19,9 +21,26 @@ import (
|
||||
//go:embed assets
|
||||
var assets embed.FS
|
||||
|
||||
// editorBundleVersion is a short content hash of the vendored CodeMirror bundle,
|
||||
// appended as ?v=… to its <script> src. The bundle is served immutable under a
|
||||
// stable filename, so without this query a rebuilt bundle would never reach a
|
||||
// client that already cached the old one (this is the editor cache-bust knob).
|
||||
var editorBundleVersion = hashAsset("assets/editor/vendor/codemirror.bundle.js")
|
||||
|
||||
func hashAsset(name string) string {
|
||||
b, err := assets.ReadFile(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
var (
|
||||
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
||||
editTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
||||
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
||||
editTmpl = template.Must(template.New("edit").Funcs(template.FuncMap{
|
||||
"editorBundleVersion": func() string { return editorBundleVersion },
|
||||
}).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
||||
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
||||
)
|
||||
|
||||
@@ -39,7 +58,9 @@ type specialPage struct {
|
||||
}
|
||||
|
||||
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
|
||||
// handle returns nil when the handler does not apply to the given path.
|
||||
// handle returns nil when the handler does not apply to the given path. The
|
||||
// request is passed read-only (e.g. query params selecting a view variant);
|
||||
// mutations belong in the POST flow, not here.
|
||||
// redirect returns ok=true with an absolute URL when the request should be
|
||||
// short-circuited with a 302 redirect (e.g. persistent date links in a diary,
|
||||
// or virtual diary URLs in edit mode that delegate to the year file's editor).
|
||||
@@ -47,7 +68,7 @@ type specialPage struct {
|
||||
// When adding a new hook, prefer a sibling method here over folding logic
|
||||
// into main.go or render.go.
|
||||
type pageTypeHandler interface {
|
||||
handle(root, fsPath, urlPath string) *specialPage
|
||||
handle(root, fsPath, urlPath string, r *http.Request) *specialPage
|
||||
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
|
||||
}
|
||||
|
||||
@@ -245,7 +266,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
var special *specialPage
|
||||
if !editMode {
|
||||
for _, ph := range pageTypeHandlers {
|
||||
if special = ph.handle(h.root, fsPath, urlPath); special != nil {
|
||||
if special = ph.handle(h.root, fsPath, urlPath, r); special != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -115,13 +115,14 @@ func wikiTargetHref(target string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// wikiTargetExists reports whether the on-disk folder backing the target
|
||||
// exists under root.
|
||||
// wikiTargetExists reports whether the on-disk path backing the target exists
|
||||
// under root. Any existing path — file or folder — counts as resolved; only a
|
||||
// missing path is treated as broken.
|
||||
func wikiTargetExists(root, target string) bool {
|
||||
target = normalizeWikiTarget(target)
|
||||
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
||||
info, err := os.Stat(fsPath)
|
||||
return err == nil && info.IsDir()
|
||||
_, err := os.Stat(fsPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
|
||||
|
||||
Reference in New Issue
Block a user