From 0e9c7b5464bac882a8edf0249b71157a3c4fdc37 Mon Sep 17 00:00:00 2001 From: Felipe Fernandes Date: Wed, 8 Apr 2026 21:24:27 -0300 Subject: [PATCH] add #tag placeholder and beaver reminder for tasks without tags --- README.md | 9 +- index.html | 17 ++- static/app.js | 382 +++++++++++++++++++++++++++++++---------------- static/style.css | 201 +++++++++++++++++++++++-- 4 files changed, 464 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index 5c7d380..0b8e296 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 @@ -218,7 +225,7 @@ Later items can be marked as done. Each later item shows a green ✓ button (to 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 4a5b35d..3a352ea 100644 --- a/index.html +++ b/index.html @@ -126,11 +126,19 @@
- - + + + + + new
- +
@@ -176,7 +184,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.

  • Freelancers tracking billable hours
  • @@ -186,6 +194,7 @@
    • Instant search — find or create a task in one keystroke
    • +
    • Optional #tags on tasks (autocomplete as you type)
    • Pomodoro timer built in
    • Full keyboard navigation (j/k, Enter, Escape)
    • Daily & weekly history
    • diff --git a/static/app.js b/static/app.js index 037bd18..0f8abe9 100644 --- a/static/app.js +++ b/static/app.js @@ -46,11 +46,17 @@ applyTheme(); const GUEST_KEY = 'tt_guest_tasks'; const GUEST_DONE_KEY = 'tt_guest_done'; const GUEST_TRIAL_KEY = 'tt_guest_trial_start'; +const TAG_TIP_DISMISS_KEY = 'doingit_tag_tip_dismissals'; +const TAG_TIP_DISMISS_KEY_LEGACY = 'doingit_project_tag_tip_dismissals'; +const TAG_TIP_THRESHOLD = 3; +const TAG_TIP_MAX_SHOWS = 2; const FREE_LIMIT = 5; -const MAX_PROJECT_SUGGESTIONS = 6; +const MAX_TAG_SUGGESTIONS = 6; let data = { tasks: [], later: [], projects: [] }; +/** While `confirm()` is open, blur has no meaningful relatedTarget — skip clearing the search draft. */ +let suppressSearchBlurClear = false; -function normalizeProjectName(name = '') { +function normalizeTagName(name = '') { return name.trim().replace(/\s+/g, ' ').toLowerCase(); } @@ -61,103 +67,204 @@ function ensureDataShape() { if (!Array.isArray(data.projects)) data.projects = []; const byNormalized = new Map(); - data.projects.forEach(project => { - if (!project || typeof project !== 'object') return; - const normalizedName = normalizeProjectName(project.normalizedName || project.name || ''); + data.projects.forEach(tagDef => { + if (!tagDef || typeof tagDef !== 'object') return; + const normalizedName = normalizeTagName(tagDef.normalizedName || tagDef.name || ''); if (!normalizedName || byNormalized.has(normalizedName)) return; byNormalized.set(normalizedName, { - id: project.id || crypto.randomUUID(), - name: normalizeProjectName(project.name || normalizedName), + id: tagDef.id || crypto.randomUUID(), + name: normalizeTagName(tagDef.name || normalizedName), normalizedName, - createdAt: project.createdAt || Date.now(), + createdAt: tagDef.createdAt || Date.now(), }); }); data.projects = Array.from(byNormalized.values()); - const validProjectIds = new Set(data.projects.map(project => project.id)); + const validTagIds = new Set(data.projects.map(t => t.id)); data.tasks.forEach(task => { if (!Array.isArray(task.sessions)) task.sessions = []; - if (!task.projectId || !validProjectIds.has(task.projectId)) task.projectId = null; + if (!task.projectId || !validTagIds.has(task.projectId)) task.projectId = null; }); } -function getProjectById(projectId) { +function getTagById(projectId) { if (!projectId) return null; - return data.projects.find(project => project.id === projectId) || null; + return data.projects.find(t => t.id === projectId) || null; } -function projectNameForTask(task) { - return getProjectById(task.projectId)?.name || null; +function tagNameForTask(task) { + return getTagById(task.projectId)?.name || null; } function taskLabel(task) { - const project = projectNameForTask(task); - return project ? `${task.name} #${project}` : task.name; + const tag = tagNameForTask(task); + return tag ? `${task.name} #${tag}` : task.name; } -function upsertProjectByName(rawName) { - const normalizedName = normalizeProjectName(rawName); +function upsertTagByName(rawName) { + const normalizedName = normalizeTagName(rawName); if (!normalizedName) return null; - const existing = data.projects.find(project => project.normalizedName === normalizedName); + const existing = data.projects.find(t => t.normalizedName === normalizedName); if (existing) return existing.id; - const project = { + const tagDef = { id: crypto.randomUUID(), name: normalizedName, normalizedName, createdAt: Date.now(), }; - data.projects.push(project); - return project.id; + data.projects.push(tagDef); + return tagDef.id; } function linkedTasksCount(projectId) { return data.tasks.filter(task => task.projectId === projectId).length; } -function deleteProjectById(projectId) { - const project = getProjectById(projectId); - if (!project) return; +function deleteTagById(projectId) { + const tag = getTagById(projectId); + if (!tag) return; const affected = linkedTasksCount(projectId); const suffix = affected - ? ` ${affected} task${affected === 1 ? '' : 's'} will keep their history without project.` + ? ` ${affected} task${affected === 1 ? '' : 's'} will keep their history without a tag.` : ''; - if (!confirm(`Delete project "#${project.name}"?${suffix}`)) return; + const searchInput = document.getElementById('search'); + const searchSnapshot = searchInput ? searchInput.value : ''; + + suppressSearchBlurClear = true; + let confirmed = false; + try { + confirmed = confirm(`Delete tag "#${tag.name}"?${suffix}`); + } finally { + suppressSearchBlurClear = false; + } + + if (!confirmed) { + if (searchInput) { + searchInput.value = searchSnapshot; + searchInput.focus(); + } + return; + } + data.projects = data.projects.filter(item => item.id !== projectId); data.tasks.forEach(task => { if (task.projectId === projectId) task.projectId = null; }); + + if (searchInput) { + const ctx = parseTagAutocompleteContext(searchSnapshot); + // Back to “only task name”: drop the #… fragment after removing a tag from the list. + searchInput.value = ctx ? (ctx.taskPart || '') : searchSnapshot; + searchInput.focus(); + } persist(); } function parseTaskInput(rawInput) { const input = (rawInput || '').trim(); - if (!input) return { taskName: '', projectName: null, hasProject: false }; - const match = input.match(/^(.*?)\s+#(.*)$/); - if (!match) return { taskName: input, projectName: null, hasProject: false }; + if (!input) return { taskName: '', tagName: null, hasTag: false }; + const match = input.match(/^(.*?)\s*#(.*)$/); + if (!match) return { taskName: input, tagName: null, hasTag: false }; const taskName = match[1].trim(); - const projectName = normalizeProjectName(match[2] || ''); - if (!projectName) return { taskName, projectName: null, hasProject: false }; + const tagName = normalizeTagName(match[2] || ''); + if (!tagName) return { taskName, tagName: null, hasTag: false }; return { taskName, - projectName, - hasProject: true, + tagName, + hasTag: true, }; } -function parseProjectAutocompleteContext(rawInput) { +function parseTagAutocompleteContext(rawInput) { const input = rawInput || ''; - const match = input.match(/^(.*?)\s+#(.*)$/); + const match = input.match(/^(.*?)\s*#(.*)$/); if (!match) return null; return { taskPart: match[1].trim(), - typedProject: normalizeProjectName(match[2] || ''), + typedTag: normalizeTagName(match[2] || ''), }; } +let untaggedCreatesSinceLastTip = 0; +let tagTipOpen = false; +let tagTipOutsideBound = false; + +function tagTipDismissCount() { + let raw = localStorage.getItem(TAG_TIP_DISMISS_KEY); + if (raw == null) { + raw = localStorage.getItem(TAG_TIP_DISMISS_KEY_LEGACY); + if (raw != null) { + localStorage.setItem(TAG_TIP_DISMISS_KEY, raw); + localStorage.removeItem(TAG_TIP_DISMISS_KEY_LEGACY); + } + } + const n = parseInt(raw || '0', 10); + return Number.isFinite(n) ? Math.min(TAG_TIP_MAX_SHOWS, Math.max(0, n)) : 0; +} + +function noteUntaggedTaskCreated() { + if (tagTipDismissCount() >= TAG_TIP_MAX_SHOWS) return; + if (tagTipOpen) return; + untaggedCreatesSinceLastTip += 1; + if (untaggedCreatesSinceLastTip >= TAG_TIP_THRESHOLD) { + tagTipOpen = true; + untaggedCreatesSinceLastTip = 0; + } +} + +function tagTipOutsideHandler(e) { + const tip = document.getElementById('tag-tip'); + const prompt = document.querySelector('.search-prompt'); + if (tip?.contains(e.target) || prompt?.contains(e.target)) return; + dismissTagTip(); +} + +function dismissTagTip() { + if (!tagTipOpen) return; + tagTipOpen = false; + const next = tagTipDismissCount() + 1; + localStorage.setItem(TAG_TIP_DISMISS_KEY, String(next)); + syncTagTip(); +} + +/** User created a task with a #tag — hide tip and never show it again. */ +function suppressTagTipUserLearned() { + tagTipOpen = false; + untaggedCreatesSinceLastTip = 0; + localStorage.setItem(TAG_TIP_DISMISS_KEY, String(TAG_TIP_MAX_SHOWS)); + syncTagTip(); +} + +function syncTagTip() { + const el = document.getElementById('tag-tip'); + if (!el) return; + const dismissals = tagTipDismissCount(); + const show = tagTipOpen && dismissals < TAG_TIP_MAX_SHOWS; + const textEl = el.querySelector('.tag-tip-text'); + if (show) { + el.classList.add('visible'); + el.setAttribute('aria-hidden', 'false'); + if (textEl) { + textEl.textContent = 'add a tag like this: task #work'; + } + if (!tagTipOutsideBound) { + document.addEventListener('mousedown', tagTipOutsideHandler, true); + tagTipOutsideBound = true; + } + } else { + el.classList.remove('visible'); + el.setAttribute('aria-hidden', 'true'); + if (tagTipOutsideBound) { + document.removeEventListener('mousedown', tagTipOutsideHandler, true); + tagTipOutsideBound = false; + } + } +} + function createOrFindTaskFromQuery(rawInput) { const parsed = parseTaskInput(rawInput); if (!parsed.taskName) return null; - const projectId = parsed.hasProject ? upsertProjectByName(parsed.projectName) : null; + const projectId = parsed.hasTag ? upsertTagByName(parsed.tagName) : null; const existing = data.tasks.find(task => task.name.toLowerCase() === parsed.taskName.toLowerCase() && (task.projectId || null) === (projectId || null) @@ -165,6 +272,8 @@ function createOrFindTaskFromQuery(rawInput) { if (existing) return existing; const task = { id: crypto.randomUUID(), name: parsed.taskName, sessions: [], projectId }; data.tasks.unshift(task); + if (!projectId) noteUntaggedTaskCreated(); + else suppressTagTipUserLearned(); return task; } @@ -1058,9 +1167,9 @@ const totalRow = document.getElementById('total-row'); const hdRunning = document.getElementById('header-running'); const hdDate = document.getElementById('header-date'); const historyEl = document.getElementById('history'); -const projectAutocompleteEl = document.getElementById('project-autocomplete'); -let projectSuggestions = []; -let projectSelIdx = -1; +const tagAutocompleteEl = document.getElementById('tag-autocomplete'); +let tagSuggestions = []; +let tagSelIdx = -1; hdDate.textContent = new Date().toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' @@ -1081,89 +1190,89 @@ function filtered() { .slice(0, 5 - todayTasks.length); return sortRunningFirst([...todayTasks, ...recent]); } - const projectContext = parseProjectAutocompleteContext(query()); - if (projectContext) { + const tagContext = parseTagAutocompleteContext(query()); + if (tagContext) { return data.tasks.filter(task => { - const taskNameMatch = task.name.toLowerCase().includes(projectContext.taskPart.toLowerCase()); - const taskProject = (projectNameForTask(task) || '').toLowerCase(); - const projectMatch = !projectContext.typedProject || taskProject.startsWith(projectContext.typedProject); - return taskNameMatch && projectMatch; + const taskNameMatch = task.name.toLowerCase().includes(tagContext.taskPart.toLowerCase()); + const taskTag = (tagNameForTask(task) || '').toLowerCase(); + const tagMatch = !tagContext.typedTag || taskTag.startsWith(tagContext.typedTag); + return taskNameMatch && tagMatch; }); } return data.tasks.filter(t => t.name.toLowerCase().includes(q)); } -function projectSuggestionsForInput(rawInput) { - const context = parseProjectAutocompleteContext(rawInput); - if (!context || !context.taskPart) return []; +function tagSuggestionsForInput(rawInput) { + const context = parseTagAutocompleteContext(rawInput); + if (!context) return []; - const typed = context.typedProject; + const typed = context.typedTag; const existing = data.projects - .filter(project => !typed || project.normalizedName.startsWith(typed)) + .filter(t => !typed || t.normalizedName.startsWith(typed)) .sort((a, b) => a.name.localeCompare(b.name)) - .slice(0, MAX_PROJECT_SUGGESTIONS) - .map(project => ({ kind: 'existing', projectName: project.normalizedName, label: project.name, projectId: project.id })); + .slice(0, MAX_TAG_SUGGESTIONS) + .map(t => ({ kind: 'existing', tagName: t.normalizedName, label: t.name, tagId: t.id })); - const hasExact = data.projects.some(project => project.normalizedName === typed); + const hasExact = data.projects.some(t => t.normalizedName === typed); if (typed && !hasExact) { existing.unshift({ kind: 'create', - projectName: typed, - label: `create project "${typed}"`, + tagName: typed, + label: `create tag "${typed}"`, }); } return existing; } -function closeProjectAutocomplete() { - projectSuggestions = []; - projectSelIdx = -1; - projectAutocompleteEl.innerHTML = ''; - projectAutocompleteEl.style.display = 'none'; +function closeTagAutocomplete() { + tagSuggestions = []; + tagSelIdx = -1; + tagAutocompleteEl.innerHTML = ''; + tagAutocompleteEl.style.display = 'none'; } -function renderProjectAutocomplete() { - if (!projectSuggestions.length) { - closeProjectAutocomplete(); +function renderTagAutocomplete() { + if (!tagSuggestions.length) { + closeTagAutocomplete(); return; } - projectAutocompleteEl.style.display = 'block'; - projectAutocompleteEl.innerHTML = projectSuggestions.map((suggestion, idx) => ` -
      - ${suggestion.kind === 'existing' ? ` - + ` : ''}
      `).join(''); } -function updateProjectAutocomplete(resetSelection = false) { - projectSuggestions = projectSuggestionsForInput(searchEl.value); - if (!projectSuggestions.length) { - closeProjectAutocomplete(); +function updateTagAutocomplete(resetSelection = false) { + tagSuggestions = tagSuggestionsForInput(searchEl.value); + if (!tagSuggestions.length) { + closeTagAutocomplete(); return; } - if (resetSelection || projectSelIdx < 0) { - projectSelIdx = 0; + if (resetSelection || tagSelIdx < 0) { + tagSelIdx = 0; } else { - projectSelIdx = Math.min(projectSelIdx, projectSuggestions.length - 1); + tagSelIdx = Math.min(tagSelIdx, tagSuggestions.length - 1); } - renderProjectAutocomplete(); + renderTagAutocomplete(); } -function applyProjectSuggestion(suggestion) { - const context = parseProjectAutocompleteContext(searchEl.value); +function applyTagSuggestion(suggestion) { + const context = parseTagAutocompleteContext(searchEl.value); if (!context || !context.taskPart) return; - searchEl.value = `${context.taskPart} #${suggestion.projectName}`; - closeProjectAutocomplete(); + searchEl.value = `${context.taskPart} #${suggestion.tagName}`; + closeTagAutocomplete(); } -function selectedProjectSuggestion() { - if (!projectSuggestions.length) return null; - return projectSuggestions[Math.max(projectSelIdx, 0)] || projectSuggestions[0] || null; +function selectedTagSuggestion() { + if (!tagSuggestions.length) return null; + return tagSuggestions[Math.max(tagSelIdx, 0)] || tagSuggestions[0] || null; } function sortRunningFirst(tasks) { @@ -1203,7 +1312,7 @@ function tasksForDay(dateStr) { return { id: t.id, name: t.name, - projectName: projectNameForTask(t), + tagName: tagNameForTask(t), sessions, ms: sessions.reduce((a, s) => a + (s.end - s.start), 0) }; @@ -1262,7 +1371,7 @@ function renderHistory() { }
` : ''; return `
- ${esc(t.name)}${t.projectName ? ` #${esc(t.projectName)}` : ''} + ${esc(t.name)}${t.tagName ? ` #${esc(t.tagName)}` : ''} ${fmt(t.ms)} @@ -1415,7 +1524,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) { @@ -1427,11 +1536,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`); @@ -1449,7 +1558,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('  ·  '); @@ -1480,13 +1589,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)); @@ -1551,7 +1660,7 @@ function render() { ${i < 9 ? i + 1 : i === 9 ? 0 : ''} ${esc(task.name)} - ${projectNameForTask(task) ? `#${esc(projectNameForTask(task))}` : ''} + ${tagNameForTask(task) ? `#${esc(tagNameForTask(task))}` : ''} ${isRecent ? '' : (() => { if (isRunning) { @@ -1595,6 +1704,7 @@ function render() { renderLater(); ensureTick(); updateTabTitle(); + syncTagTip(); } // ── Keyboard ────────────────────────────────────────────────────────────────── @@ -1603,7 +1713,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); @@ -1632,33 +1742,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(); }); @@ -1666,40 +1780,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; } @@ -1736,25 +1850,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(); }); @@ -2026,7 +2140,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 8497b39..bf99283 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); } @@ -1405,6 +1572,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;