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/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/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/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", 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`,