append first notebook
I use note taking apps to remember things about people, write down project ideas and thoughts, keep track of upcoming birthdays and recent addresses to send letters.
I also particularly care about logging data like my spending, my cats weight or the time I spent playing games on a particular date. I then like to aggregate and analyze this data because everything else is prompts.
In this article I want to explore two ways to update information about a particular concept or person, and a way to quickly log information. and show a vibe coded demo that tries something different.
When I used obsidian, there were too many ways to do things. For example, if I want to update some life event about a friend Alice, I can navigate to their page and, maybe make a new header with todays date and write the down update. I call this concept-page first approach. This is clean, because each page has information specific to it, but there is overhead in going to the page and making a date entry even with keyboard shortcuts or slash commands.
Bob.md
# 2025-01-02
Met at a conference, he is traveling to Indonesia next week.
# 2025-01-12
He got engaged?!
The other alternative is to write the paragraph in todays journal and tag or link Alice. In former case, I can see a lot of context about Alice, including when what happened with them and how things change over time.
In the latter case, I have a a good overview of the day where I can see multiple notes about multiple people and other events. I can scroll back in time and see a snapshot of the day. A particular journal paragraph can link to multiple people so there is no duplication and I don't have to open 3 pages if I have an event with all 3.
2025-01-02.md
Met [[Bob]] at a conference, he is traveling to Indonesia next week.
I think [[Alice]] was asking about ring sizes.
With backlinks, can still aggregate all notes mentioning a concept. Clicking on #Alice could give me all journal entries with them, so we can get a overview of blocks. However, I noticed the implementation backlinks can differ a lot.
Sometimes it is just the title, or some number of characters, or maybe a paragraph. It is actually fairly finnicky to implement in a way that feels pleasing. (Maybe configurable backlink rendering is the next hot PKM thing?) In the apps I used, it did not give you the full context of a person when you mention them, so I still had friction because I had to open the individual journal page.
The final part I want to talk about before showing the demo is tracking metrics. In lifelab, I have a data block called spending where I log how much I spent on some item. I add the spending entries to everytime I do a purchase. Then, I have a code block which aggregates spending blocks and lets me know how much budget I have left for the year. I pin it in my sidebar to have an ad-hoc dashboard. I also track my game time and our cats weight, because the cat is obese. Now, there are two ways to do this in Obsidian.
- Have a cat.md file with a markdown table with dates and the weight. Updating it means I need to open the file, add a row with the date and write down the latest weight. This is scriptable using templater but it is still a sequence of things you need to do and some overhead.
| Date | Weight (kg) | Notes |
|------------|-------------|-----------------|
| 2025-01-05 | 6.8 | post-vet visit |
| 2025-01-12 | 6.7 | |
- Update the current journal page frontmatter with a key-value. You can aggregate this later using obsidian databases or any scripts since it is programatically extractable. The problem here is you clutter the journal frontmatter with a lot of data and it isn't super clear when it starts and ends, and you need to query all journal data to collect the weight because you don't know in which files it is added.
---
date: 2025-01-11
cat_weight: 6.5
spending: 45.50
game_time: 2.5
---
Aggregating over this can actually be annoying because frontmatter might have some entries missing when I don't add them! Now we are enforcing the data to be in all journal entries.
What if we can tag a person (or anything), which simultaneously opens the page? It feels like skipping a half second step is superfluous but it felt pretty good for me. In this claude coded demo, you can write (tag) a entity or concept and immediately add information to it. After pressing enter, it is appended to the page with todays date. Alternatively, you can type @Alice and press enter, this opens a view collection of all notes about them. If you tag multiple concepts, the text will be added to all of them. You can also filter using multiple tags using intersection
As a bonus, it can do light regex to automatically collect data. If you writ @weight 190 lbs, it detects the number using regex and stores it. Everything after the number is considered a comment and not highlighted. If you open @weight, it shows a trend (computed from numbers) on the header. You can use this to track any metrics and it is fairly fast to add, and there is no configuration needed, just start adding a new metric like @mood and after adding 3 entries it will automatically compute trends for you.
To create a task, just write @tomorrow TODO task. There is a special tag (@upcoming) that aggregates all future tags from today. All entries have implicit journal day tags so you can reconstruct the given day.
This is not that good for long form writing but it is fairly easy to quickly add information and capture metrics on the fly (significantly faster than my current approach).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Append-First Notebook</title>
<link rel="icon" href="data:,">
<script src="https://cdn.tailwindcss.com"></script>
<style>
[data-loading] { opacity: 0; }
body.loaded [data-loading] { opacity: 1; transition: opacity 0.15s; }
</style>
</head>
<body class="bg-gray-50">
<div id="root" data-loading></div>
<!-- ═══════════════════════════════════════════════════════════════════════
THE DATABASE - This JSON holds all your data.
The app rewrites this section when you save.
═══════════════════════════════════════════════════════════════════════ -->
<script type="application/json" id="notebook-data">
{
"blocks": [
{"id": "1", "titles": ["Alice", "person"], "content": "Met at conference, works on distributed systems", "createdAt": "2025-01-03T10:00:00.000Z"},
{"id": "2", "titles": ["Alice", "ProjectX"], "content": "Coffee chat - she might join the project", "createdAt": "2025-01-08T14:30:00.000Z"},
{"id": "3", "titles": ["ProjectX"], "content": "Initial roadmap draft complete", "createdAt": "2025-01-05T09:00:00.000Z"},
{"id": "4", "titles": ["weight"], "content": "185 lbs starting point", "createdAt": "2025-01-03T08:00:00.000Z"},
{"id": "5", "titles": ["weight"], "content": "183 lbs", "createdAt": "2025-01-05T08:00:00.000Z"},
{"id": "6", "titles": ["weight"], "content": "181.5 lbs feeling good", "createdAt": "2025-01-08T08:00:00.000Z"},
{"id": "7", "titles": ["TODO", "Alice"], "content": "Send project proposal", "createdAt": "2025-01-09T09:00:00.000Z", "isTask": true, "done": false}
],
"meta": {
"created": "2025-01-03T00:00:00.000Z",
"lastModified": "2025-01-09T00:00:00.000Z"
}
}
</script>
<!-- ═══════════════════════════════════════════════════════════════════════
THE APP - Preact + HTM, no build step needed
═══════════════════════════════════════════════════════════════════════ -->
<script type="module">
import { h, render } from 'https://esm.sh/preact@10.19.3';
import { useState, useEffect, useRef, useMemo, useCallback } from 'https://esm.sh/preact@10.19.3/hooks';
import htm from 'https://esm.sh/htm@3.1.1';
const html = htm.bind(h);
// ─────────────────────────────────────────────────────────────────────────
// STORAGE
// ─────────────────────────────────────────────────────────────────────────
let fileHandle = null;
function loadData() {
const el = document.getElementById('notebook-data');
try {
const data = JSON.parse(el.textContent);
data.blocks = data.blocks.map(b => ({ ...b, createdAt: new Date(b.createdAt) }));
return data;
} catch (e) {
return { blocks: [], meta: { created: new Date().toISOString() } };
}
}
function saveDataToElement(data) {
const el = document.getElementById('notebook-data');
const serializable = {
...data,
blocks: data.blocks.map(b => ({ ...b, createdAt: b.createdAt.toISOString() })),
meta: { ...data.meta, lastModified: new Date().toISOString() }
};
el.textContent = JSON.stringify(serializable, null, 2);
}
async function saveFile() {
// Clone and clean DOM to prevent bloat
const clone = document.documentElement.cloneNode(true);
const root = clone.querySelector('#root');
if (root) root.innerHTML = '';
clone.querySelector('body').classList.remove('loaded');
const htmlStr = '<!DOCTYPE html>\n' + clone.outerHTML;
if (fileHandle) {
try {
const writable = await fileHandle.createWritable();
await writable.write(htmlStr);
await writable.close();
return { method: 'filesystem', success: true };
} catch (e) { console.error(e); }
}
const blob = new Blob([htmlStr], { type: 'text/html' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'notebook.html';
a.click();
URL.revokeObjectURL(a.href);
return { method: 'download', success: true };
}
async function requestFileAccess() {
if (!('showSaveFilePicker' in window)) return false;
try {
fileHandle = await window.showSaveFilePicker({
suggestedName: 'notebook.html',
types: [{ description: 'HTML', accept: { 'text/html': ['.html'] } }]
});
return true;
} catch (e) { return false; }
}
// ─────────────────────────────────────────────────────────────────────────
// DATE HELPERS
// ─────────────────────────────────────────────────────────────────────────
const TODAY = new Date().toISOString().split('T')[0];
const TOMORROW = new Date(Date.now() + 86400000).toISOString().split('T')[0];
const getNextWeekday = (day) => {
const d = new Date();
let diff = day - d.getDay();
if (diff <= 0) diff += 7;
return new Date(Date.now() + diff * 86400000).toISOString().split('T')[0];
};
const DATE_ALIASES = ['today', 'tomorrow', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
function parseDateAlias(alias) {
const l = alias.toLowerCase();
if (l === 'today') return TODAY;
if (l === 'tomorrow') return TOMORROW;
if (l === 'monday') return getNextWeekday(1);
if (l === 'tuesday') return getNextWeekday(2);
if (l === 'wednesday') return getNextWeekday(3);
if (l === 'thursday') return getNextWeekday(4);
if (l === 'friday') return getNextWeekday(5);
if (/^\d{4}-\d{2}-\d{2}$/.test(alias)) return alias;
return null;
}
// ─────────────────────────────────────────────────────────────────────────
// PARSING
// ─────────────────────────────────────────────────────────────────────────
function parseInput(input) {
let titles = [], content = input;
// Multi-word @[title]
for (const m of input.matchAll(/@\[([^\]]+)\]/g)) {
titles.push(parseDateAlias(m[1]) || m[1]);
}
content = content.replace(/@\[([^\]]+)\]/g, '');
// Single-word @title
for (const m of content.matchAll(/@(\w+)(?=\s|$)/g)) {
titles.push(parseDateAlias(m[1]) || m[1]);
}
content = content.replace(/@(\w+)(?=\s|$)/g, '').trim().replace(/\s+/g, ' ');
let isTask = false, isDone = false;
if (/\bTODO\b/i.test(content)) {
isTask = true;
if (!titles.includes('TODO')) titles.push('TODO');
content = content.replace(/\bTODO\b/i, '').trim();
}
if (/\bDONE\b/i.test(content)) {
isTask = true; isDone = true;
if (!titles.includes('DONE')) titles.push('DONE');
content = content.replace(/\bDONE\b/i, '').trim();
}
return { titles: [...new Set(titles)], content, isTask, isDone };
}
function parseStructured(content) {
const m = content.match(/^([\d.]+)\s*(lbs?|kg|km|mi|hrs?|mins?|%)?\s*(.*)$/i);
if (m) return { value: parseFloat(m[1]), unit: m[2] || null, note: m[3] || null, isNumeric: true };
return { isNumeric: false };
}
function detectStreamType(blocks) {
if (blocks.length < 2) return { type: 'mixed', values: [] };
let unit = null;
const values = [];
for (const b of blocks) {
const p = parseStructured(b.content);
if (p.isNumeric) {
values.push({ value: p.value, date: b.createdAt, note: p.note });
if (p.unit && !unit) unit = p.unit;
}
}
return {
type: values.length / blocks.length >= 0.5 ? 'numeric' : 'mixed',
unit,
values: values.sort((a, b) => a.date - b.date)
};
}
// ─────────────────────────────────────────────────────────────────────────
// COMPONENTS
// ─────────────────────────────────────────────────────────────────────────
function Sparkline({ values, width = 60, height = 20 }) {
if (values.length < 2) return null;
const nums = values.map(v => v.value);
const min = Math.min(...nums), max = Math.max(...nums), range = max - min || 1;
const points = nums.map((v, i) => `${(i / (nums.length - 1)) * width},${height - ((v - min) / range) * height}`).join(' ');
const trend = nums[nums.length - 1] - nums[0];
const color = trend < 0 ? '#10b981' : trend > 0 ? '#f59e0b' : '#6b7280';
return html`
<div class="flex items-center gap-2">
<svg width=${width} height=${height}>
<polyline points=${points} fill="none" stroke=${color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx=${width} cy=${height - ((nums[nums.length-1] - min) / range) * height} r="3" fill=${color}/>
</svg>
<span class="text-xs" style="color: ${color}">${trend > 0 ? '↑' : trend < 0 ? '↓' : '→'}${Math.abs(trend).toFixed(1)}</span>
</div>
`;
}
function Block({ block, queryTitles, onTitleClick, onToggle, onUpdate, onDelete }) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(block.content);
const ref = useRef();
useEffect(() => { if (editing && ref.current) ref.current.focus(); }, [editing]);
const otherTitles = block.titles.filter(t => !queryTitles.includes(t) && t !== 'TODO' && t !== 'DONE' && !/^\d{4}-\d{2}-\d{2}$/.test(t));
const parsed = parseStructured(block.content);
const dateStr = block.createdAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const save = () => { onUpdate(block.id, value); setEditing(false); };
const keydown = (e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(block.content); setEditing(false); } };
return html`
<div class="group py-2 px-3 hover:bg-gray-50 rounded-lg">
<div class="flex items-start gap-3">
<span class="text-xs text-gray-400 mt-1 w-16 flex-shrink-0">${dateStr}</span>
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2">
${block.isTask && html`
<button onClick=${() => onToggle(block.id)} class="mt-0.5 w-4 h-4 rounded border-2 flex-shrink-0 flex items-center justify-center ${block.done ? 'bg-green-500 border-green-500 text-white' : 'border-gray-300 hover:border-indigo-400'}">
${block.done && html`<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg>`}
</button>
`}
${editing ? html`
<input ref=${ref} value=${value} onInput=${e => setValue(e.target.value)} onKeyDown=${keydown} onBlur=${save}
class="flex-1 bg-white border border-indigo-300 rounded px-2 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-200"/>
` : html`
<p class="leading-relaxed cursor-pointer ${block.done ? 'text-gray-400 line-through' : 'text-gray-800'}" onDblClick=${() => setEditing(true)}>
${parsed.isNumeric ? html`
<span class="font-semibold text-emerald-600">${parsed.value}</span>
${parsed.unit && html`<span class="text-emerald-500 ml-0.5">${parsed.unit}</span>`}
${parsed.note && html`<span class="text-gray-600 ml-2">${parsed.note}</span>`}
` : block.content}
</p>
`}
${!editing && html`
<div class="opacity-0 group-hover:opacity-100 flex gap-1 transition-opacity">
<button onClick=${() => setEditing(true)} class="p-1 text-gray-400 hover:text-indigo-600 rounded text-sm">✎</button>
<button onClick=${() => onDelete(block.id)} class="p-1 text-gray-400 hover:text-red-600 rounded text-sm">×</button>
</div>
`}
</div>
${otherTitles.length > 0 && html`
<div class="flex gap-1.5 mt-1 ml-6 flex-wrap">
${otherTitles.map(t => html`<button key=${t} onClick=${() => onTitleClick([t])} class="text-xs text-indigo-600 hover:underline">@${t}</button>`)}
</div>
`}
</div>
</div>
</div>
`;
}
function TitleStream({ query, blocks, onCollapse, onTitleClick, onAppend, onToggle, onUpdate, onDelete }) {
const [input, setInput] = useState('');
const ref = useRef();
const isUpcoming = query.length === 1 && query[0] === 'upcoming';
const queryBlocks = useMemo(() => {
if (isUpcoming) {
return blocks
.filter(b => b.titles.some(t => /^\d{4}-\d{2}-\d{2}$/.test(t) && t > TODAY))
.sort((a, b) => {
const da = a.titles.find(t => /^\d{4}-\d{2}-\d{2}$/.test(t)) || '';
const db = b.titles.find(t => /^\d{4}-\d{2}-\d{2}$/.test(t)) || '';
return da.localeCompare(db);
});
}
return blocks.filter(b => query.every(t => b.titles.includes(t))).sort((a, b) => a.createdAt - b.createdAt);
}, [blocks, query, isUpcoming]);
const streamInfo = useMemo(() => detectStreamType(queryBlocks), [queryBlocks]);
const isMetric = streamInfo.type === 'numeric' && streamInfo.values.length >= 2;
const isTodo = query.includes('TODO');
useEffect(() => { ref.current?.focus(); }, []);
const handleKey = (e) => {
if (e.key === 'Enter' && input.trim()) { onAppend(query, input); setInput(''); }
if (e.key === 'Escape') onCollapse(query);
};
const headerColor = isTodo ? 'from-amber-50' : isMetric ? 'from-emerald-50' : 'from-indigo-50';
const titleColor = isTodo ? 'text-amber-700' : isMetric ? 'text-emerald-700' : 'text-indigo-600';
return html`
<div class="border border-gray-200 rounded-xl bg-white shadow-sm overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-gradient-to-r ${headerColor} to-white border-b border-gray-100">
<div class="flex items-center gap-2">
<span class="font-semibold ${titleColor}">
${query.map((t, i) => html`<span key=${t}>${i > 0 && html`<span class="text-gray-400 mx-1">∩</span>`}@${t === TODAY ? 'today' : t}</span>`)}
</span>
<span class="text-xs text-gray-400">${queryBlocks.length}</span>
${isMetric && html`<span class="text-xs bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded">metric</span>`}
${isTodo && html`<span class="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded">tasks</span>`}
</div>
<div class="flex items-center gap-3">
${isMetric && html`
<div class="flex items-center gap-2">
<${Sparkline} values=${streamInfo.values}/>
<span class="text-sm font-medium text-gray-700">
${streamInfo.values[streamInfo.values.length - 1].value}
${streamInfo.unit && html`<span class="text-gray-400 ml-0.5">${streamInfo.unit}</span>`}
</span>
</div>
`}
<button onClick=${() => onCollapse(query)} class="text-gray-400 hover:text-gray-600 p-1 text-lg">×</button>
</div>
</div>
<div class="divide-y divide-gray-50 max-h-96 overflow-y-auto">
${queryBlocks.length === 0
? html`<div class="px-4 py-6 text-center text-gray-400 text-sm italic">Empty stream</div>`
: queryBlocks.map(b => html`<${Block} key=${b.id} block=${b} queryTitles=${query} onTitleClick=${onTitleClick} onToggle=${onToggle} onUpdate=${onUpdate} onDelete=${onDelete}/>`)}
</div>
<div class="border-t border-gray-100 p-3 bg-gray-50">
<div class="flex items-center gap-2">
<span class="text-gray-400">→</span>
<input ref=${ref} value=${input} onInput=${e => setInput(e.target.value)} onKeyDown=${handleKey}
placeholder="Append to ${query.map(t => '@' + t).join(' ')}..."
class="flex-1 bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-200"/>
</div>
</div>
</div>
`;
}
function Autocomplete({ input, allTitles, onSelect, activeIndex, setActiveIndex }) {
const { titles } = parseInput(input);
const lastAt = input.lastIndexOf('@');
if (lastAt === -1) return null;
const afterAt = input.slice(lastAt + 1);
if (afterAt.includes(' ') && !afterAt.startsWith('[')) return null;
const partial = afterAt.replace(/[\[\]]/g, '').toLowerCase();
if (!partial) return null;
const special = ['TODO', 'DONE', 'upcoming', ...DATE_ALIASES];
const matches = useMemo(() =>
[...new Set([...special, ...allTitles])]
.filter(t => t.toLowerCase().includes(partial) && !titles.includes(t))
.slice(0, 6),
[partial, allTitles, titles]
);
useEffect(() => { setActiveIndex(0); }, [partial]);
if (matches.length === 0) return null;
return html`
<div class="absolute left-0 right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden z-20">
${matches.map((title, i) => {
const resolved = parseDateAlias(title);
const color = title === 'TODO' ? 'text-amber-600' : title === 'DONE' ? 'text-green-600' : title === 'upcoming' ? 'text-purple-600' : 'text-indigo-600';
return html`
<button key=${title} onClick=${() => onSelect(resolved || title, lastAt)}
class="w-full text-left px-4 py-2 text-sm flex justify-between ${i === activeIndex ? 'bg-indigo-50' : 'hover:bg-gray-50'}">
<span class="font-medium ${color}">@${title}</span>
${resolved && html`<span class="text-xs text-gray-400">${resolved}</span>`}
</button>
`;
})}
</div>
`;
}
// ─────────────────────────────────────────────────────────────────────────
// APP
// ─────────────────────────────────────────────────────────────────────────
function App() {
const [data, setData] = useState(loadData);
const [queries, setQueries] = useState([]);
const [input, setInput] = useState('');
const [saved, setSaved] = useState(true);
const [saveMethod, setSaveMethod] = useState(null);
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef();
const blocks = data.blocks;
const allTitles = useMemo(() => [...new Set(blocks.flatMap(b => b.titles))].sort(), [blocks]);
// Save to embedded JSON on change
useEffect(() => { saveDataToElement(data); setSaved(false); }, [data]);
// Cmd+S handler
useEffect(() => {
const handler = async (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
await saveFile();
setSaved(true);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
const updateBlocks = (newBlocks) => setData({ ...data, blocks: newBlocks });
const queriesEqual = (a, b) => a.length === b.length && a.every(t => b.includes(t));
const expandQuery = (titles) => {
const arr = Array.isArray(titles) ? titles : [titles];
if (!queries.some(q => queriesEqual(q, arr))) setQueries([...queries, arr]);
};
const collapseQuery = (titles) => setQueries(queries.filter(q => !queriesEqual(q, titles)));
const appendToQuery = (queryTitles, content) => {
const parsed = parseInput(content);
let allT = [...queryTitles, ...parsed.titles];
if (!allT.some(t => /^\d{4}-\d{2}-\d{2}$/.test(t))) allT.push(TODAY);
updateBlocks([...blocks, {
id: Date.now().toString(),
titles: [...new Set(allT)],
content: parsed.content || content,
createdAt: new Date(),
...(parsed.isTask && { isTask: true, done: parsed.isDone })
}]);
};
const toggleTask = (id) => {
updateBlocks(blocks.map(b => {
if (b.id !== id) return b;
const done = !b.done;
const titles = b.titles.filter(t => t !== 'TODO' && t !== 'DONE');
titles.push(done ? 'DONE' : 'TODO');
return { ...b, done, titles };
}));
};
const updateBlock = (id, content) => updateBlocks(blocks.map(b => b.id === id ? { ...b, content } : b));
const deleteBlock = (id) => updateBlocks(blocks.filter(b => b.id !== id));
// Get autocomplete matches for keyboard nav
const getMatches = () => {
const { titles } = parseInput(input);
const lastAt = input.lastIndexOf('@');
if (lastAt === -1) return [];
const afterAt = input.slice(lastAt + 1);
if (afterAt.includes(' ') && !afterAt.startsWith('[')) return [];
const partial = afterAt.replace(/[\[\]]/g, '').toLowerCase();
if (!partial) return [];
const special = ['TODO', 'DONE', 'upcoming', ...DATE_ALIASES];
return [...new Set([...special, ...allTitles])].filter(t => t.toLowerCase().includes(partial) && !titles.includes(t)).slice(0, 6);
};
const handleAutocomplete = (title, atPos) => {
const before = input.slice(0, atPos);
const replacement = title.includes(' ') ? `@[${title}] ` : `@${title} `;
const newInput = before + replacement;
const { titles, content } = parseInput(newInput);
if (titles.length > 0 && !content.trim()) {
expandQuery(titles);
setInput('');
} else {
setInput(newInput);
}
inputRef.current?.focus();
};
const handleMainKey = (e) => {
const matches = getMatches();
// Arrow nav for autocomplete
if (matches.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, matches.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && matches.length > 0 && activeIndex >= 0)) {
e.preventDefault();
const title = matches[activeIndex];
const resolved = parseDateAlias(title);
handleAutocomplete(resolved || title, input.lastIndexOf('@'));
return;
}
}
if (e.key === 'Enter' && input.trim()) {
const parsed = parseInput(input);
if (parsed.titles.length === 0) {
const resolved = parseDateAlias(input.trim());
expandQuery([resolved || input.trim()]);
} else if (parsed.content) {
let allT = [...parsed.titles];
if (!allT.some(t => /^\d{4}-\d{2}-\d{2}$/.test(t))) allT.push(TODAY);
updateBlocks([...blocks, {
id: Date.now().toString(),
titles: allT,
content: parsed.content,
createdAt: new Date(),
...(parsed.isTask && { isTask: true, done: parsed.isDone })
}]);
const nonDate = parsed.titles.filter(t => !/^\d{4}-\d{2}-\d{2}$/.test(t) && t !== 'TODO' && t !== 'DONE');
if (nonDate.length) expandQuery(nonDate);
} else {
expandQuery(parsed.titles);
}
setInput('');
}
};
const setupFileAccess = async () => {
if (await requestFileAccess()) {
setSaveMethod('filesystem');
await saveFile();
setSaved(true);
}
};
return html`
<div class="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 p-6">
<div class="max-w-2xl mx-auto">
<div class="text-center mb-6">
<h1 class="text-2xl font-bold text-gray-800 mb-1">Append-First Notebook</h1>
<p class="text-gray-500 text-sm">Type <code class="bg-gray-200 px-1.5 py-0.5 rounded">@title</code> to summon. Add content to append.</p>
</div>
<div class="flex justify-center gap-2 mb-4">
${!saveMethod ? html`
<button onClick=${setupFileAccess} class="text-xs text-indigo-600 hover:underline">Enable auto-save to this file</button>
` : html`
<span class="text-xs ${saved ? 'text-green-600' : 'text-amber-600'}">${saved ? '✓ Saved' : '● Unsaved'} (Cmd+S)</span>
`}
</div>
<div class="relative mb-4">
<div class="flex items-center gap-3 bg-white border-2 border-gray-200 rounded-xl px-4 py-3 shadow-sm focus-within:border-indigo-400 transition-colors">
<span class="text-xl text-gray-300">›</span>
<input ref=${inputRef} value=${input} onInput=${e => setInput(e.target.value)} onKeyDown=${handleMainKey}
placeholder="@Alice had coffee, discussed @ProjectX"
class="flex-1 text-lg focus:outline-none placeholder:text-gray-300"/>
</div>
<${Autocomplete} input=${input} allTitles=${allTitles} onSelect=${handleAutocomplete} activeIndex=${activeIndex} setActiveIndex=${setActiveIndex}/>
</div>
<div class="flex flex-wrap gap-2 mb-6">
<button onClick=${() => expandQuery([TODAY])} class="px-3 py-1.5 bg-sky-50 text-sky-700 rounded-lg text-sm font-medium hover:bg-sky-100 border border-sky-200">📅 Today</button>
<button onClick=${() => expandQuery(['TODO'])} class="px-3 py-1.5 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 border border-amber-200">☐ Tasks</button>
<button onClick=${() => expandQuery(['upcoming'])} class="px-3 py-1.5 bg-purple-50 text-purple-700 rounded-lg text-sm font-medium hover:bg-purple-100 border border-purple-200">🔮 Upcoming</button>
<button onClick=${() => expandQuery(['weight'])} class="px-3 py-1.5 bg-emerald-50 text-emerald-700 rounded-lg text-sm font-medium hover:bg-emerald-100 border border-emerald-200">📊 Weight</button>
</div>
${allTitles.length > 0 && html`
<div class="flex flex-wrap gap-2 mb-6">
<span class="text-xs text-gray-400 self-center">Titles:</span>
${allTitles.filter(t => !/^\d{4}-\d{2}-\d{2}$/.test(t) && t !== 'TODO' && t !== 'DONE').map(t => html`
<button key=${t} onClick=${() => expandQuery([t])}
class="px-2 py-0.5 rounded text-sm font-medium ${queries.some(q => q.length === 1 && q[0] === t) ? 'bg-indigo-100 text-indigo-800 border border-indigo-300' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
${t}
</button>
`)}
</div>
`}
<div class="space-y-4">
${queries.map(q => html`
<${TitleStream} key=${q.join('+')} query=${q} blocks=${blocks}
onCollapse=${collapseQuery} onTitleClick=${expandQuery} onAppend=${appendToQuery}
onToggle=${toggleTask} onUpdate=${updateBlock} onDelete=${deleteBlock}/>
`)}
</div>
${queries.length === 0 && html`
<div class="text-center py-16 text-gray-400">
<div class="text-4xl mb-4">✨</div>
<p>No streams open. Type a title above to begin.</p>
</div>
`}
<div class="mt-8 text-center text-xs text-gray-400">
${blocks.length} blocks · ${allTitles.length} titles · Single HTML file · Preact + HTM
</div>
</div>
</div>
`;
}
// Boot
document.body.classList.add('loaded');
render(html`<${App}/>`, document.getElementById('root'));
</script>
</body>
</html>