From 7568c1c3f24f4c36219506e5a8e34b81df627a90 Mon Sep 17 00:00:00 2001 From: "snake.tech" <9079973+snaketech-tu@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:38:11 +0000 Subject: [PATCH 1/2] Keep typed From and To times in the range menu The menu filled its fields from the range on screen in an effect that also tracked the range, which moves with the clock and the store, so every refresh wiped what was being typed. Fill them only when the menu opens. From and To are now text fields read like the range box, with a calendar button that opens the browser's picker; segmented date fields were hard to type into. Tests type them key by key and wait through a refresh. --- CHANGELOG.md | 6 ++ e2e/tests/logs.spec.ts | 120 ++++++++++++++++++++++----- ui/src/components/RangePicker.svelte | 38 +++++---- ui/src/components/TimeField.svelte | 86 +++++++++++++++++++ ui/src/lib/icons.ts | 1 + ui/src/lib/timeframe.ts | 14 ++++ 6 files changed, 227 insertions(+), 38 deletions(-) create mode 100644 ui/src/components/TimeField.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d90250..91903b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Release notes for each version. The section of a version becomes the text of its GitHub release, and Stackline shows it in the update dialog. +## 0.7.1 + +### Fixed +- **From and To times in the time range menu can be set again.** While the menu was open, every refresh of the page (every few seconds) put both fields, and the typed range, back to the range on screen, so a time being entered was wiped before it could be shown. The fields now keep what you type until you show the range or close the menu +- **From and To are plain text fields with a calendar button.** The browser's date fields split a time into small parts that are hard to type into (the year takes more than four digits, so the time never got focus). Type `2026-11-10 08:15`, `10.11. 8:15`, `yesterday 14:00` or `14:00`, or pick from the calendar; the line under each field says how it was read. Enter shows the range, the up and down arrows move a minute (with Shift an hour) + ## 0.7.0 ### Added diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts index f2134a9..784f7f7 100644 --- a/e2e/tests/logs.spec.ts +++ b/e2e/tests/logs.spec.ts @@ -176,28 +176,106 @@ test('a typed time range shows those lines, and the arrows step through time', a await page.keyboard.press('Escape') }) -test('from and to fields pick an exact range, and nonsense is refused', async ({ page }) => { - await open(page, 'logs') - await page.getByRole('button', { name: /Custom/ }).click() - const dialog = page.getByRole('dialog', { name: 'Time range' }) - await dialog.getByRole('textbox', { name: 'Type a time range' }).fill('soon-ish') - await expect(dialog).toContainText('Not a range yet') - - const pad = (n: number) => String(n).padStart(2, '0') - const local = (t: number) => { - const d = new Date(t) - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +test.describe('From and To', () => { + // A 24-hour locale and a fixed zone, so typed times mean the same everywhere. + test.use({ locale: 'de-DE', timezoneId: 'Europe/Berlin' }) + + /** `dd.mm. HH:MM` of a moment `minsAgo` back, in the page's zone, and that moment to the minute. */ + async function moment(page: import('@playwright/test').Page, minsAgo: number) { + return page.evaluate((m) => { + const d = new Date(Math.floor((Date.now() - m * 60e3) / 60e3) * 60e3) + const p = (n: number) => String(n).padStart(2, '0') + return { text: `${p(d.getDate())}.${p(d.getMonth() + 1)}. ${p(d.getHours())}:${p(d.getMinutes())}`, t: d.getTime() } + }, minsAgo) } - const now = Date.now() - await dialog.getByLabel('From').fill(local(now - 2 * 3600e3)) - await dialog.getByLabel(/^To/).fill(local(now - 3 * 3600e3)) - await dialog.getByRole('button', { name: 'Show this range' }).click() - await expect(dialog).toContainText('The end must come after the start.') - await dialog.getByLabel(/^To/).fill(local(now - 60e3)) - await dialog.getByRole('button', { name: 'Show this range' }).click() - await expect(dialog).toHaveCount(0) - await expect(page).toHaveURL(/[#&?]t=\d+-\d+(&|$)/) - await expect(page.locator('.stream .row').first()).toBeVisible() + + test('typed From and To times stay while the page refreshes, and Enter shows exactly that range', async ({ page }) => { + await open(page, 'logs') + await page.keyboard.press('t') + const dialog = page.getByRole('dialog', { name: 'Time range' }) + const from = dialog.getByRole('textbox', { name: 'From', exact: true }) + const to = dialog.getByRole('textbox', { name: 'To', exact: true }) + const a = await moment(page, 120) + const z = await moment(page, 30) + + // Typed key by key, the way a person does it. + await from.click() + await page.keyboard.press('Control+a') + await page.keyboard.type(a.text) + await to.click() + await page.keyboard.press('Control+a') + await page.keyboard.type(z.text) + await expect(dialog).not.toContainText('Not a time yet') + + // The page reloads its lines and the store meanwhile; what was typed must stay. + await page.waitForTimeout(16_000) + await expect(from).toHaveValue(a.text) + await expect(to).toHaveValue(z.text) + + await to.press('Enter') + await expect(dialog).toHaveCount(0) + await expect(page).toHaveURL(new RegExp(`[#&?]t=${a.t}-${z.t}(&|$)`)) + await expect(page.locator('.stream .row').first()).toBeVisible() + }) + + test('an empty To runs until now, arrows nudge a time, and nonsense is refused', async ({ page }) => { + await open(page, 'logs') + await page.getByRole('button', { name: /Custom/ }).click() + const dialog = page.getByRole('dialog', { name: 'Time range' }) + const from = dialog.getByRole('textbox', { name: 'From', exact: true }) + const to = dialog.getByRole('textbox', { name: 'To', exact: true }) + await expect(to).toHaveValue('') + await expect(dialog).toContainText('Empty: until now') + + await from.fill('soon-ish') + await expect(dialog).toContainText('Not a time yet') + await dialog.getByRole('button', { name: 'Show this range' }).click() + await expect(dialog.getByRole('alert')).toContainText('The start is not a time') + + const a = await moment(page, 90) + await from.fill(a.text) + await to.fill((await moment(page, 100)).text) + await from.press('Enter') + await expect(dialog.getByRole('alert')).toContainText('The end must come after the start.') + + // Up and down move a minute, with Shift an hour. + await from.press('ArrowUp') + await from.press('Shift+ArrowDown') + const nudged = await from.inputValue() + await to.fill('') + await from.press('Enter') + await expect(dialog).toHaveCount(0) + const start = a.t + 60e3 - 3600e3 + expect(nudged).toMatch(/^\d{4}-\d\d-\d\d \d\d:\d\d$/) + await expect(page).toHaveURL(new RegExp(`[#&?]t=${start}-now(&|$)`)) + }) + + test('the calendar button opens the browser picker and a picked time lands in the field', async ({ page }) => { + await open(page, 'logs') + // Headless Chromium draws no picker window, so note the call and answer it the way the picker does. + await page.evaluate(() => { + ;(window as unknown as { picks: number }).picks = 0 + HTMLInputElement.prototype.showPicker = function () { + ;(window as unknown as { picks: number }).picks++ + } + }) + await page.keyboard.press('t') + const dialog = page.getByRole('dialog', { name: 'Time range' }) + await dialog.getByRole('button', { name: 'Calendar for from' }).click() + expect(await page.evaluate(() => (window as unknown as { picks: number }).picks)).toBe(1) + const picked = await page.evaluate(() => { + const d = new Date(Date.now() - 3 * 3600e3) + const p = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}` + }) + const ghost = dialog.locator('input[type="datetime-local"]').first() + await ghost.evaluate((el: HTMLInputElement, v) => { + el.value = v + el.dispatchEvent(new Event('change', { bubbles: true })) + }, picked) + await expect(dialog.getByRole('textbox', { name: 'From', exact: true })).toHaveValue(picked.replace('T', ' ')) + await expect(dialog).toBeVisible() + }) }) test('Ctrl K takes a time range', async ({ page }) => { diff --git a/ui/src/components/RangePicker.svelte b/ui/src/components/RangePicker.svelte index 5a81530..1da7514 100644 --- a/ui/src/components/RangePicker.svelte +++ b/ui/src/components/RangePicker.svelte @@ -1,10 +1,12 @@ + +
+
+ + + oninput?.()} /> + + + + +
+ {meaning} +
+ + diff --git a/ui/src/lib/icons.ts b/ui/src/lib/icons.ts index bee94c5..15e418d 100644 --- a/ui/src/lib/icons.ts +++ b/ui/src/lib/icons.ts @@ -31,6 +31,7 @@ export const ICON: Record = { chevL: '', x: '', filter: '', + calendar: '', clock: '', plus: '', check: '', diff --git a/ui/src/lib/timeframe.ts b/ui/src/lib/timeframe.ts index 36fae7f..56889b5 100644 --- a/ui/src/lib/timeframe.ts +++ b/ui/src/lib/timeframe.ts @@ -142,6 +142,20 @@ function point(text: string, now: number): { t: number; day?: boolean } | null { return null } +/** + * One moment typed in a From or To field: `2026-11-10 08:15`, `10.11. 8:15`, + * `yesterday 14:00`, `14:00`, `2h ago`. A day alone is its midnight. Null + * when it means nothing. + */ +export function parsePoint(text: string, now = Date.now()): number | null { + return point(text, now)?.t ?? null +} + +/** A moment as text for a From or To field: `2026-11-10 08:15`. */ +export function fieldValue(t: number): string { + return inputValue(t).replace('T', ' ') +} + /** * Reads a typed range. Understands, among others: * `15m`, `last 2 hours`, `today`, `yesterday`, `this week`, `since 14:00`, From 8946920c2a6eaa8ae0955056f20b88f8cbbc4200 Mon Sep 17 00:00:00 2001 From: "snake.tech" <9079973+snaketech-tu@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:38:28 +0000 Subject: [PATCH 2/2] Release 0.7.1 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- desktop/package-lock.json | 4 ++-- desktop/package.json | 2 +- ui/package-lock.json | 4 ++-- ui/package.json | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b2625b7..a12c156 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3971,7 +3971,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stackline" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "axum", @@ -3999,7 +3999,7 @@ dependencies = [ [[package]] name = "stackline-core" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "bytes", @@ -4020,7 +4020,7 @@ dependencies = [ [[package]] name = "stackline-desktop" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 618e629..15b3035 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/stackline-core", "crates/stackline", "desktop/src-tauri"] default-members = ["crates/stackline-core", "crates/stackline"] [workspace.package] -version = "0.7.0" +version = "0.7.1" edition = "2024" license = "Apache-2.0" repository = "https://github.com/snaketech-tu/stackline" diff --git a/desktop/package-lock.json b/desktop/package-lock.json index d0500e1..f64e64b 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "stackline-desktop", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stackline-desktop", - "version": "0.7.0", + "version": "0.7.1", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/desktop/package.json b/desktop/package.json index fbc27d1..003d11f 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "stackline-desktop", "private": true, - "version": "0.7.0", + "version": "0.7.1", "scripts": { "tauri": "tauri" }, diff --git a/ui/package-lock.json b/ui/package-lock.json index 19bee7c..ced1117 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "stackline-ui", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stackline-ui", - "version": "0.7.0", + "version": "0.7.1", "dependencies": { "@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/plus-jakarta-sans": "^5.3.0" diff --git a/ui/package.json b/ui/package.json index 598088e..c949999 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "stackline-ui", "private": true, - "version": "0.7.0", + "version": "0.7.1", "type": "module", "scripts": { "dev": "vite",