Compare commits

..

7 Commits

Author SHA1 Message Date
luxick d719b53404 Add weekly averages for weight 2026-06-12 14:10:00 +02:00
luxick f870a12cd5 Adjust button sizes 2026-06-11 14:48:04 +02:00
luxick 0b62cd50f3 Fitness dashboard without calorie tracker
Does not compute correctly yet
2026-06-11 13:03:54 +02:00
luxick fde4eff12d Fitness dashboard v1 2026-06-11 12:55:43 +02:00
luxick 4f14b39d16 Improve Editor on mobile 2026-06-09 22:18:16 +02:00
luxick 204e89dbce Improve wiki linking 2026-06-09 18:36:40 +02:00
luxick 5525a03179 Pin toolbar to the bottom on mobile 2026-06-05 10:47:38 +02:00
12 changed files with 805 additions and 95 deletions
+1 -1
View File
@@ -13,6 +13,7 @@
<div class="editor-toolbar"> <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="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="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> <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="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> <button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
@@ -44,7 +45,6 @@
</span> </span>
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button> <button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button>
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button> <button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)"></button>
<span class="toolbar-sep"></span> <span class="toolbar-sep"></span>
<span class="dropdown"> <span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button> <button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
+101 -13
View File
@@ -35,6 +35,10 @@
CM.drawSelection(), CM.drawSelection(),
CM.indentOnInput(), CM.indentOnInput(),
CM.EditorView.lineWrapping, 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.markdown({ base: CM.markdownLanguage }),
CM.syntaxHighlighting(CM.highlightStyle), CM.syntaxHighlighting(CM.highlightStyle),
CM.closeBrackets(), CM.closeBrackets(),
@@ -172,17 +176,76 @@
movie: function () { M.run(movieCtx); }, movie: function () { M.run(movieCtx); },
}; };
// Wiki link button: drop an empty [[]] at the cursor and open the `[[` // isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
// completion popup so the same autocomplete flow handles target selection. // 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() { function insertWikilink() {
var sel = view.state.selection.main; var sel = view.state.selection.main;
view.dispatch({ var selectedText = view.state.sliceDoc(sel.from, sel.to);
changes: { from: sel.from, to: sel.to, insert: '[[]]' },
selection: { anchor: sel.from + 2 }, var container = document.createElement('div');
scrollIntoView: true,
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 --- // --- Keyboard shortcut registration ---
@@ -198,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) { document.addEventListener('keydown', function (e) {
if (!e.altKey || !e.shiftKey) return; if (!e.altKey || !e.shiftKey) return;
// Shift+digit produces a layout-dependent character in e.key (e.g. "!" // Shift+digit produces a layout-dependent character in e.key (e.g. "!"
@@ -212,15 +288,27 @@
// --- Dropdowns --- // --- Dropdowns ---
// The toolbar scrolls horizontally on mobile, which makes it an overflow // The toolbar scrolls horizontally (so it clips its absolutely-positioned
// container that would clip its absolutely-positioned dropdown menus. Pin an // menus) and on mobile is fixed to the bottom of the viewport. Pin an open
// open menu to the viewport under its trigger so it escapes the clip. // menu to the viewport so it escapes the clip, opening upward when there
var toolbar = document.querySelector('.editor-toolbar'); // isn't room below it (the bottom-toolbar case).
function pinMenu(toggle, menu) { function pinMenu(toggle, menu) {
if (!menu.classList.contains('is-open')) return; if (!menu.classList.contains('is-open')) return;
var r = toggle.getBoundingClientRect(); var r = toggle.getBoundingClientRect();
var vh = window.innerHeight;
menu.style.position = 'fixed'; menu.style.position = 'fixed';
menu.style.top = r.bottom + 'px'; 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); var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
menu.style.left = Math.max(4, left) + 'px'; menu.style.left = Math.max(4, left) + 'px';
} }
+88 -66
View File
@@ -1,93 +1,115 @@
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror. // wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
// //
// Triggers when the cursor sits in a `[[…` token (freshly typed or re-edited // A level-by-level folder/file browser scoped to the path typed so far. It
// inside an existing `[[…]]`), queries the existing /_search JSON endpoint // fires only once the `[[` token's content begins with `/` (targets are
// (debounced), and offers matching page paths. Selecting a result inserts the // absolute; free-text search lives in the toolbar modal instead). The content
// absolute path and auto-closes `]]`, leaving the cursor before the close so a // is split into a parent path (up to and including the last `/`) and a partial
// `::display` alias can be typed (decision 7). Exposes window.WikiComplete.source // 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(). // for main.js to register via CM's autocompletion().
window.WikiComplete = (function () { window.WikiComplete = (function () {
var DEBOUNCE_MS = 100; // treeURL builds the `?tree=1` request URL for an absolute parent path,
var MIN_QUERY_LEN = 2; // percent-encoding each segment. A leading/trailing slash is tolerated;
// root resolves to `/?tree=1`.
// Mirrors the Go validator (wikilinks.go isValidWikiTarget) and the old function treeURL(parent) {
// client check: absolute path, no empty/'.'/'..' segments. Server results var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
// are wiki paths so this is mostly a guard against odd index entries. if (trimmed === '') return '/?tree=1';
function isValidWikiTarget(p) { var enc = trimmed.split('/').map(encodeURIComponent).join('/');
if (!p || p[0] !== '/') return false; return '/' + enc + '/?tree=1';
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;
} }
// Build the apply() for a chosen result. Inserts the path over the typed function fetchTree(parent) {
// query and ensures a single trailing `]]`, cursor parked before it. If the return fetch(treeURL(parent), {
// 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), {
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
}).then(function (r) { }).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); if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json(); return r.json();
}); });
} }
// CM completion source. `[[` plus any run of non-`]`, non-newline chars // makeApply builds the apply() for a chosen entry. It replaces only the
// before the cursor is the trigger; the query is the text after `[[`. The // current segment ([from, to]); the trailing `]]` is untouched, so the
// replaced range extends past the cursor to the end of the inner token (up // cursor ends up parked before it. Folders append `/` and re-open the popup
// to `]]`/EOL) so re-editing inside an existing `[[…]]` replaces the whole // to drill into the next level; files terminate.
// target instead of duplicating the trailing text. 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) { function source(context) {
var match = context.matchBefore(/\[\[[^\]\n]*/); var match = context.matchBefore(/\[\[[^\]\n]*/);
if (!match) return null; if (!match) return null;
var query = context.state.sliceDoc(match.from + 2, context.pos); var content = context.state.sliceDoc(match.from + 2, context.pos);
if (query.length < MIN_QUERY_LEN && !context.explicit) return null; 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 line = context.state.doc.lineAt(context.pos);
var to = 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) { return new Promise(function (resolve) {
var timer = setTimeout(function () { if (context.aborted) { resolve(null); return; }
if (context.aborted) { resolve(null); return; } fetchTree(parent).then(function (resp) {
fetchSuggest(query).then(function (resp) { if (context.aborted || !resp) { resolve(null); return; }
if (context.aborted) { resolve(null); return; } var needle = partial.toLowerCase();
var options = (resp.results || []).reduce(function (acc, r) { var options = (resp.entries || []).reduce(function (acc, e) {
var path = '/' + r.path; if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
if (isValidWikiTarget(path)) {
acc.push({ label: path, detail: r.name, apply: makeApply(path) });
}
return acc; return acc;
}, []); }
resolve({ from: match.from + 2, to: to, options: options }); acc.push({
}).catch(function () { label: e.name,
resolve(null); type: e.kind === 'folder' ? 'folder' : 'file',
}); apply: makeApply(e.name, e.kind),
}, DEBOUNCE_MS); });
// CM doesn't cancel pending promises, but it sets context.aborted; return acc;
// clear the timer too if the doc moved on before it fired. }, []);
if (context.addEventListener) { // No validFor: the source re-runs on each keystroke, so every
context.addEventListener('abort', function () { clearTimeout(timer); }); // 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 };
})(); })();
+10
View File
@@ -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();
});
+43
View File
@@ -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
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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> <title>{{.Title}}</title>
<link rel="icon" href="/_/favicon.ico" /> <link rel="icon" href="/_/favicon.ico" />
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
+1 -1
View File
@@ -17,7 +17,7 @@ function openViewSettings() {
options.forEach(function (opt) { options.forEach(function (opt) {
var b = document.createElement('button'); var b = document.createElement('button');
b.type = 'button'; b.type = 'button';
b.className = 'btn btn-small'; b.className = 'btn';
b.textContent = opt.label; b.textContent = opt.label;
if (state[key] === opt.value) b.classList.add('is-active'); if (state[key] === opt.value) b.classList.add('is-active');
b.addEventListener('click', function () { b.addEventListener('click', function () {
+45 -4
View File
@@ -70,6 +70,7 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
.gap-2 { gap: var(--space-2); } .gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); } .gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); } .gap-4 { gap: var(--space-4); }
.space-between { justify-content: space-between; }
.divider-dashed { border-bottom: var(--border-dashed); } .divider-dashed { border-bottom: var(--border-dashed); }
/* === Page layout === /* === Page layout ===
@@ -166,7 +167,7 @@ footer {
.btn::before { content: "["; color: var(--secondary); } .btn::before { content: "["; color: var(--secondary); }
.btn::after { content: "]"; color: var(--secondary); } .btn::after { content: "]"; color: var(--secondary); }
.btn:hover { color: var(--primary-hover); } .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-tool { padding: 0 0.15rem; }
.btn-block { .btn-block {
display: flex; display: flex;
@@ -654,6 +655,28 @@ aside.sidebar:empty { display: none; }
.diary-cal-grid td.cal-current a { color: var(--primary-hover); } .diary-cal-grid td.cal-current a { color: var(--primary-hover); }
.btn-block.cal-current { 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 === */ /* === Responsive === */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.page-wrap { grid-template-columns: 1fr; } .page-wrap { grid-template-columns: 1fr; }
@@ -695,9 +718,27 @@ aside.sidebar:empty { display: none; }
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 2.75rem; min-width: 2rem;
min-height: 2.75rem; min-height: 2rem;
padding: 0 var(--space-2); 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-backdrop { padding: var(--space-2); align-items: flex-start; }
.modal { max-width: none; margin-top: var(--space-4); } .modal { max-width: none; margin-top: var(--space-4); }
+1 -2
View File
@@ -159,7 +159,7 @@ func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok b
return "", "", "", false 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) depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
if !ok { if !ok {
return nil return nil
@@ -544,7 +544,6 @@ func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntr
return weeks return weeks
} }
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix. // diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
type diaryPhoto struct { type diaryPhoto struct {
Date time.Time Date time.Time
+504
View File
@@ -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),
}}
}
+5 -3
View File
@@ -58,7 +58,9 @@ type specialPage struct {
} }
// pageTypeHandler is implemented by each special folder type (diary, gallery, …). // 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 // 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, // 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). // or virtual diary URLs in edit mode that delegate to the year file's editor).
@@ -66,7 +68,7 @@ type specialPage struct {
// When adding a new hook, prefer a sibling method here over folding logic // When adding a new hook, prefer a sibling method here over folding logic
// into main.go or render.go. // into main.go or render.go.
type pageTypeHandler interface { 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) redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
} }
@@ -264,7 +266,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
var special *specialPage var special *specialPage
if !editMode { if !editMode {
for _, ph := range pageTypeHandlers { 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 break
} }
} }
+5 -4
View File
@@ -115,13 +115,14 @@ func wikiTargetHref(target string) string {
return b.String() return b.String()
} }
// wikiTargetExists reports whether the on-disk folder backing the target // wikiTargetExists reports whether the on-disk path backing the target exists
// exists under root. // 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 { func wikiTargetExists(root, target string) bool {
target = normalizeWikiTarget(target) target = normalizeWikiTarget(target)
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/"))) fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
info, err := os.Stat(fsPath) _, err := os.Stat(fsPath)
return err == nil && info.IsDir() return err == nil
} }
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root. // wikiDefaultDisplay returns the last segment of a target, or "/" for the root.