diff --git a/README.md b/README.md index 9eddd4b..3923c38 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ pytest Tests use transaction-per-test rollback for fast, isolated runs against a real Postgres instance. No mocking of the database layer. +End-to-end checks for the static UI live under `tests/e2e/` (Playwright). With Node 18+, from the repo root: `npm install` then `npx playwright test tests/e2e/task-crud.e2e.mjs` (or another file in that folder). + CI runs automatically on every push and pull request via GitHub Actions. ## Deploying to Fly.io @@ -173,9 +175,14 @@ Then open `http://localhost:8000/?token=` manually to reach the reset for | `↑` `↓` | Navigate the task list | | `Tab` | Expand/collapse today's session log for the selected task | | `Esc` | Clear the search | +| `#` | Optional tag: type `task name` then `#tagname` (space optional). Autocomplete appears after `#`. | You can also click any task row to start/stop it, and hover to reveal the `✕` delete button. +**Tags (discovery)** + +The search placeholder shows an example with `#`. While the search field is focused, the hint row shows keyboard shortcuts for the search field. After you create **three new tasks without a tag**, the beaver mascot may show a short tip (at most **twice** per browser)—e.g. *“add a tag like this: task #work”*; dismiss with **×** or by clicking outside the mascot area. As soon as you **create a new task that includes a tag** (`#something`), the tip closes and will not appear again — same as if you had dismissed it twice. Dismissals are stored in `localStorage` under `doingit_tag_tip_dismissals` (older builds used `doingit_project_tag_tip_dismissals`; the app migrates that value once on read). + Only one task runs at a time — starting a new one automatically stops the current one. ## Seed data @@ -251,7 +258,7 @@ Later (to-do) items can be reordered by dragging. Hover over an item to reveal t All task data is stored per-user in a Postgres database. Locally this is the `tt` database on your Postgres.app instance. In production it's the Fly.io Postgres cluster attached to the app. -Tasks, sessions, and the “later” list are normalized into SQL tables. **`GET /data` returns them from those tables.** Projects and each task’s `projectId` are not separate tables; they are stored inside the `user_data.tasks_json` blob (the same JSON the client sends on `POST /data`). On read, the server merges that blob so **`projects` and `projectId` round-trip** with the rest of the payload—reloads and new devices see the same project tags as long as the client has synced at least once after creating projects. +Tasks, sessions, and the “later” list are normalized into SQL tables. **`GET /data` returns them from those tables.** Task tags use the UI word **tag**; in stored JSON they still live under **`projects`** (list of tag definitions) and **`projectId`** on each task (same payload the client sends on `POST /data`). On read, the server merges that blob so tags **round-trip** with the rest of the payload—reloads and new devices see the same tags after sync. ## Files diff --git a/index.html b/index.html index 4b6c69b..9086bb7 100644 --- a/index.html +++ b/index.html @@ -139,11 +139,19 @@
- - + + + + + new
- +
@@ -190,7 +198,7 @@

From to-do to done, tracked.

Type a task name, press Enter, and the clock starts. Press Escape to stop. That's it.

-

No projects, no tags, no settings. Just a text box and a timer — like Notational Velocity for your workday.

+

No folders, no clutter, no settings. Just a text box and a timer — like Notational Velocity for your workday. Use #tag on a task when you want a label.

` : ''; return `
- ${esc(t.name)}${t.projectName ? ` #${esc(t.projectName)}` : ''} + ${esc(t.name)}${t.tagName ? ` #${esc(t.tagName)}` : ''} ${fmt(t.ms)} @@ -1591,7 +1700,7 @@ function updateHintRow() { const count = filtered().length; const searchFocused = document.activeElement === searchEl; const hasRunning = !!runningTask(); - const selectedProject = selectedProjectSuggestion(); + const selectedTag = selectedTagSuggestion(); const parts = []; if (count >= 1) { @@ -1603,11 +1712,11 @@ function updateHintRow() { parts.push(`n new`); parts.push(`N later`); if (searchFocused) { - if (projectSuggestions.length) { - parts.push(`↑↓ project`); - parts.push(` close projects`); - if (selectedProject?.kind === 'existing') { - parts.push(` delete project`); + if (tagSuggestions.length) { + parts.push(`↑↓ tags`); + parts.push(` close tags`); + if (selectedTag?.kind === 'existing') { + parts.push(` delete tag`); } } else { parts.push(` start / stop`); @@ -1625,7 +1734,7 @@ function updateHintRow() { parts.push(`esc exit`); } else { if (!searchFocused) parts.push(`j/↓ navigate`); - if (hasRunning && !projectSuggestions.length) parts.push(`esc clear`); + if (hasRunning && !tagSuggestions.length) parts.push(`esc clear`); } hintRowEl.innerHTML = parts.join('  ·  '); @@ -1656,13 +1765,13 @@ function render() { // create hint (inline in search row) const parsedQuery = parseTaskInput(q); - const canCreateFromInput = parsedQuery.taskName && (!q.includes('#') || parsedQuery.hasProject); + const canCreateFromInput = parsedQuery.taskName && (!q.includes('#') || parsedQuery.hasTag); const exactMatch = tasks.find(task => task.name.toLowerCase() === parsedQuery.taskName.toLowerCase() && ( - !parsedQuery.hasProject + !parsedQuery.hasTag ? !task.projectId - : (projectNameForTask(task) || '') === parsedQuery.projectName + : (tagNameForTask(task) || '') === parsedQuery.tagName ) ); document.getElementById('search-create-hint').classList.toggle('visible', !!(canCreateFromInput && !exactMatch)); @@ -1727,7 +1836,7 @@ function render() { ${i < 9 ? i + 1 : i === 9 ? 0 : ''} ${linkify(task.name)} - ${projectNameForTask(task) ? `#${esc(projectNameForTask(task))}` : ''} + ${tagNameForTask(task) ? `#${esc(tagNameForTask(task))}` : ''} ${isRecent ? '' : (() => { if (isRunning) { @@ -1771,6 +1880,7 @@ function render() { renderLater(); ensureTick(); updateTabTitle(); + syncTagTip(); } // ── Keyboard ────────────────────────────────────────────────────────────────── @@ -1779,7 +1889,7 @@ async function startFromQuery(rawQuery) { if (!parsed.taskName) return false; if (rawQuery.includes('#')) { - if (!parsed.hasProject) return false; + if (!parsed.hasTag) return false; const taggedTask = createOrFindTaskFromQuery(rawQuery); if (!taggedTask) return false; await startTask(taggedTask); @@ -1808,33 +1918,37 @@ document.getElementById('search-create-hint').addEventListener('mousedown', asyn e.preventDefault(); // keep focus on input const q = query(); if (!q) return; - if (q.includes('#') && !parseTaskInput(q).hasProject) return; + if (q.includes('#') && !parseTaskInput(q).hasTag) return; const task = createOrFindTaskFromQuery(q); if (!task) return; await startTask(task); searchEl.value = ''; selIdx = -1; - closeProjectAutocomplete(); + closeTagAutocomplete(); render(); }); searchEl.addEventListener('input', () => { selIdx = -1; - updateProjectAutocomplete(true); + updateTagAutocomplete(true); render(); }); searchEl.addEventListener('focus', () => { navIdx = -1; - updateProjectAutocomplete(true); + updateTagAutocomplete(true); updateHintRow(); }); -searchEl.addEventListener('blur', updateHintRow); +searchEl.addEventListener('blur', e => { + updateHintRow(); + if (suppressSearchBlurClear) return; + // Focus moved into the tag menu (✕ or row) — keep draft until click/confirm finishes. + const rt = e.relatedTarget; + if (rt && tagAutocompleteEl?.contains(rt)) return; -searchEl.addEventListener('blur', () => { searchEl.value = ''; selIdx = -1; - closeProjectAutocomplete(); + closeTagAutocomplete(); render(); }); @@ -1842,40 +1956,40 @@ searchEl.addEventListener('keydown', async e => { const q = query(); const tasks = filtered(); - if (projectSuggestions.length && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + if (tagSuggestions.length && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { e.preventDefault(); const step = e.key === 'ArrowDown' ? 1 : -1; - projectSelIdx = (projectSelIdx + step + projectSuggestions.length) % projectSuggestions.length; - renderProjectAutocomplete(); + tagSelIdx = (tagSelIdx + step + tagSuggestions.length) % tagSuggestions.length; + renderTagAutocomplete(); return; } - if (projectSuggestions.length && (e.key === 'Enter' || e.key === 'Tab')) { + if (tagSuggestions.length && (e.key === 'Enter' || e.key === 'Tab')) { e.preventDefault(); - const suggestion = selectedProjectSuggestion(); + const suggestion = selectedTagSuggestion(); if (!suggestion) return; - applyProjectSuggestion(suggestion); + applyTagSuggestion(suggestion); render(); return; } - if (projectSuggestions.length && e.key === 'ArrowRight') { + if (tagSuggestions.length && e.key === 'ArrowRight') { e.preventDefault(); - const suggestion = selectedProjectSuggestion(); + const suggestion = selectedTagSuggestion(); if (!suggestion || suggestion.kind !== 'existing') return; - deleteProjectById(suggestion.projectId); - updateProjectAutocomplete(true); + deleteTagById(suggestion.tagId); + updateTagAutocomplete(true); render(); return; } - if (projectSuggestions.length && e.key === 'ArrowLeft') { + if (tagSuggestions.length && e.key === 'ArrowLeft') { e.preventDefault(); e.stopPropagation(); - const context = parseProjectAutocompleteContext(searchEl.value); + const context = parseTagAutocompleteContext(searchEl.value); if (context) searchEl.value = context.taskPart; selIdx = -1; - closeProjectAutocomplete(); + closeTagAutocomplete(); render(); return; } @@ -1912,25 +2026,25 @@ searchEl.addEventListener('keydown', async e => { if (started) { searchEl.value = ''; selIdx = -1; - closeProjectAutocomplete(); + closeTagAutocomplete(); render(); } } }); -projectAutocompleteEl.addEventListener('mousedown', e => e.preventDefault()); -projectAutocompleteEl.addEventListener('click', e => { - const deleteBtn = e.target.closest('[data-project-delete-id]'); +tagAutocompleteEl.addEventListener('mousedown', e => e.preventDefault()); +tagAutocompleteEl.addEventListener('click', e => { + const deleteBtn = e.target.closest('[data-tag-delete-id]'); if (deleteBtn) { - deleteProjectById(deleteBtn.dataset.projectDeleteId); - updateProjectAutocomplete(true); + deleteTagById(deleteBtn.dataset.tagDeleteId); + updateTagAutocomplete(true); render(); return; } - const option = e.target.closest('[data-project-name]'); + const option = e.target.closest('[data-tag-name]'); if (!option) return; - applyProjectSuggestion({ projectName: option.dataset.projectName }); + applyTagSuggestion({ tagName: option.dataset.tagName }); searchEl.focus(); render(); }); @@ -2202,7 +2316,19 @@ document.addEventListener('keydown', async e => { } }); -document.querySelector('.search-prompt').addEventListener('click', () => searchEl.focus()); +document.querySelector('.search-prompt').addEventListener('click', e => { + if (e.target.closest('.tag-tip-close')) return; + searchEl.focus(); +}); + +const tagTipClose = document.querySelector('.tag-tip-close'); +if (tagTipClose) { + tagTipClose.addEventListener('click', e => { + e.stopPropagation(); + e.preventDefault(); + dismissTagTip(); + }); +} totalRow.addEventListener('click', e => { if (e.target.closest('.total-expand')) { diff --git a/static/style.css b/static/style.css index 2219ac6..bd65d29 100644 --- a/static/style.css +++ b/static/style.css @@ -327,6 +327,7 @@ html[data-theme="system"] #auth-screen { position: relative; display: flex; align-items: center; + overflow: visible; border-bottom: 2px solid var(--border); margin-bottom: 4px; transition: border-color 0.15s; @@ -335,17 +336,183 @@ html[data-theme="system"] #auth-screen { .search-row:focus-within { border-bottom-color: var(--accent); } .search-prompt { + position: relative; flex-shrink: 0; line-height: 0; padding: 6px 2px 6px 0; user-select: none; cursor: pointer; + overflow: visible; +} + +/* Speech bubble from the beaver — tail points down toward the mascot */ +.tag-tip { + display: block; + position: absolute; + bottom: calc(100% + 10px); + left: 0; + z-index: 50; + width: max-content; + max-width: min(400px, calc(100vw - 20px)); + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateY(10px) scale(0.97); + transform-origin: 28px 100%; + cursor: default; + text-align: left; + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.07)); +} + +.tag-tip.visible { + visibility: visible; + opacity: 1; + pointer-events: auto; + transform: translateY(0) scale(1); + transition: + opacity 0.32s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.42s cubic-bezier(0.22, 1, 0.36, 1), + visibility 0s; + animation: tag-tip-breathe 3.2s ease-in-out 0.5s infinite; +} + +.tag-tip:not(.visible) { + transition: + opacity 0.2s ease-out, + transform 0.2s ease-out, + visibility 0s linear 0.2s; +} + +.tag-tip-inner { + position: relative; + box-sizing: border-box; + max-width: inherit; + padding: 12px 40px 12px 14px; + font-size: 13px; + line-height: 1.45; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 4px 20px rgba(0, 0, 0, 0.08); +} + +/* Outer tail stroke */ +.tag-tip-inner::before { + content: ''; + position: absolute; + bottom: -9px; + left: 20px; + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-top: 10px solid var(--border); +} + +/* Inner tail fill */ +.tag-tip-inner::after { + content: ''; + position: absolute; + bottom: -7px; + left: 21px; + width: 0; + height: 0; + border-left: 9px solid transparent; + border-right: 9px solid transparent; + border-top: 9px solid var(--surface); +} + +.tag-tip-text { + margin: 0; + font-style: normal; + white-space: nowrap; +} + +@media (max-width: 340px) { + .tag-tip-text { + white-space: normal; + } +} + +.tag-tip-close { + position: absolute; + top: 6px; + right: 8px; + border: none; + background: none; + color: var(--dim); + font-size: 18px; + line-height: 1; + padding: 4px 8px; + cursor: pointer; + border-radius: 6px; +} + +.tag-tip-close:hover { + color: var(--text); + background: var(--sel); +} + +@keyframes tag-tip-breathe { + 0%, + 100% { + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.07)); + } + 50% { + filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.1)); + } +} + +@keyframes beaver-mascot-speak { + 0%, + 100% { + transform: translateY(0) rotate(0deg); + } + 25% { + transform: translateY(-1px) rotate(-2deg); + } + 50% { + transform: translateY(-2px) rotate(0deg); + } + 75% { + transform: translateY(-1px) rotate(2deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .tag-tip { + transform: none; + filter: none; + } + + .tag-tip.visible { + animation: none; + transform: none; + transition: opacity 0.12s ease-out, visibility 0s; + } + + .tag-tip:not(.visible) { + transition: opacity 0.12s ease-out, visibility 0s linear 0.12s; + } + + .search-prompt:has(#tag-tip.visible) .beaver-mascot { + animation: none; + } } .beaver-mascot { height: 28px; width: auto; display: block; + transform-origin: 50% 100%; +} + +/* Gentle bob while the tag tip is open (“speaking”) */ +.search-prompt:has(#tag-tip.visible) .beaver-mascot { + animation: beaver-mascot-speak 0.7s ease-in-out 2; } #search { @@ -368,27 +535,27 @@ html[data-theme="system"] #auth-screen { padding-top: 6px; } -.project-autocomplete { +.tag-autocomplete { margin-top: 6px; border: 1px solid var(--border); background: var(--surface); } -.project-row { +.tag-row { display: flex; align-items: center; border-top: 1px solid var(--border); } -.project-row:first-of-type { +.tag-row:first-of-type { border-top: none; } -.project-row.selected { +.tag-row.selected { background: var(--sel); } -.project-option { +.tag-option { width: 100%; border: none; background: transparent; @@ -400,13 +567,13 @@ html[data-theme="system"] #auth-screen { cursor: pointer; } -.project-option:hover, -.project-option.selected { +.tag-option:hover, +.tag-option.selected { background: var(--sel); color: var(--text); } -.project-delete-btn { +.tag-delete-btn { border: none; background: none; color: var(--red); @@ -417,7 +584,7 @@ html[data-theme="system"] #auth-screen { opacity: 0.7; } -.project-delete-btn:hover { +.tag-delete-btn:hover { opacity: 1; filter: brightness(1.1); } @@ -596,7 +763,7 @@ html[data-theme="system"] #auth-screen { font-size: 16px; } -.t-project { +.t-tag { font-size: 12px; color: var(--dimmer); white-space: nowrap; @@ -700,7 +867,7 @@ html[data-theme="system"] #auth-screen { row-gap: 2px; } - .t-project { + .t-tag { order: 6; flex-basis: 100%; margin-left: 0; @@ -999,7 +1166,7 @@ html[data-theme="dark"] .sl-date-input { color-scheme: dark; } white-space: nowrap; } -.dt-project { +.dt-tag { color: var(--dimmer); } @@ -1545,6 +1712,16 @@ html[data-theme="dark"] .sl-date-input { color-scheme: dark; } .about-body p { margin-bottom: 14px; } +.about-body code { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; +} + .about-section-label { color: var(--text); font-weight: 600;