Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "stackline-desktop",
"private": true,
"version": "0.7.0",
"version": "0.7.1",
"scripts": {
"tauri": "tauri"
},
Expand Down
120 changes: 99 additions & 21 deletions e2e/tests/logs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
4 changes: 2 additions & 2 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "stackline-ui",
"private": true,
"version": "0.7.0",
"version": "0.7.1",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
38 changes: 21 additions & 17 deletions ui/src/components/RangePicker.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script lang="ts">
import { untrack } from 'svelte'
// The time range of the Logs page: one-click presets, a menu to type or pick
// any range, and steps to the range before or after.
import { app, PRESETS, type Preset } from '../lib/state.svelte'
import { inputValue, parseRange, rangeInput, presetText, rangeText, type Range } from '../lib/timeframe'
import { fieldValue, parsePoint, parseRange, rangeInput, presetText, rangeText, type Range } from '../lib/timeframe'
import Icon from './Icon.svelte'
import Popover from './Popover.svelte'
import TimeField from './TimeField.svelte'

let { open = $bindable(false) }: { open?: boolean } = $props()

Expand All @@ -25,15 +27,19 @@
/** A range that has no button of its own shows as words on the menu button. */
let custom = $derived(!(L.preset && BUTTONS.includes(L.preset)))

// Each time the menu opens, and only then, the fields start at the range on
// screen. Untracked: the range moves with the clock and the store, and
// reading it here would put the fields back while someone edits them.
$effect(() => {
if (!open) return
// Every time it opens: the fields show the range on screen.
if (open) untrack(fill)
})
function fill() {
const [a, z] = L.window()
typed = ''
error = ''
fromIn = inputValue(a)
toIn = L.moving ? '' : inputValue(z)
})
fromIn = fieldValue(a)
toIn = L.moving ? '' : fieldValue(z)
}

function label(r: Range): string {
if ('preset' in r) return r.preset === 'auto' ? 'all stored lines' : presetText(r.preset)
Expand All @@ -58,10 +64,11 @@
}

function applyFields() {
const a = fromIn ? new Date(fromIn).getTime() : NaN
const z = toIn ? new Date(toIn).getTime() : null
if (Number.isNaN(a)) return void (error = 'Pick a start.')
if (z !== null && (Number.isNaN(z) || z <= a)) return void (error = 'The end must come after the start.')
const a = parsePoint(fromIn)
const z = toIn.trim() ? parsePoint(toIn) : null
if (a === null) return void (error = 'The start is not a time Stackline understands, such as 2026-11-10 08:15.')
if (toIn.trim() && z === null) return void (error = 'The end is not a time Stackline understands, such as 2026-11-10 09:30.')
if (z !== null && z <= a) return void (error = 'The end must come after the start.')
if (a >= Date.now()) return void (error = 'The start is in the future.')
L.setRange({ from: a, to: z })
L.remember(rangeInput(a, z))
Expand Down Expand Up @@ -100,7 +107,7 @@
{:else if preview === ''}
<div class="note err">Not a range yet. Examples: {EXAMPLES.join(' · ')}</div>
{:else}
<div class="note">For example {#each EXAMPLES as ex, i (ex)}{#if i}, {/if}<button class="ex mono" onclick={() => (typed = ex)}>{ex}</button>{/each}</div>
<div class="note">For example {#each EXAMPLES as ex, i (ex)}{#if i}{', '}{/if}<button class="ex mono" onclick={() => (typed = ex)}>{ex}</button>{/each}</div>
{/if}

<div class="sepl"></div>
Expand All @@ -114,11 +121,11 @@
<div class="sepl"></div>
<div class="hd">From and to</div>
<div class="fields">
<label class="field">From<input type="datetime-local" bind:value={fromIn} step="60" oninput={() => (error = '')} /></label>
<label class="field" title="Leave empty to run until now">To<input type="datetime-local" bind:value={toIn} step="60" oninput={() => (error = '')} /></label>
<TimeField label="From" bind:value={fromIn} onenter={applyFields} oninput={() => (error = '')} />
<TimeField label="To" bind:value={toIn} empty="until now" onenter={applyFields} oninput={() => (error = '')} />
</div>
<div class="row-end">
{#if toIn}<button class="btn ghost sm" onclick={() => (toIn = '')}>Until now</button>{:else}<span class="muted hint">No end: the range runs until now.</span>{/if}
{#if toIn}<button class="btn ghost sm" onclick={() => (toIn = '')}>Until now</button>{/if}
<button class="btn pri sm" onclick={applyFields}>Show this range</button>
</div>
{#if error}<div class="note err" role="alert">{error}</div>{/if}
Expand Down Expand Up @@ -147,10 +154,7 @@
.steps button:disabled{opacity:.4;cursor:default}
.chips{display:flex;flex-wrap:wrap;gap:6px;padding:2px 10px 6px}
.fields{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px;padding:2px 10px}
.fields .field{min-width:0}
.fields input{height:34px;font-family:var(--mono);font-size:12px;min-width:0;padding:0 6px}
.row-end{display:flex;justify-content:flex-end;align-items:center;gap:6px;padding:8px 10px 4px}
.hint{font-size:11.5px;margin-right:auto}
.note.err{color:var(--err)}
.ex{all:unset;cursor:pointer;color:var(--accent);font-size:11.5px}
.ex:hover{text-decoration:underline}
Expand Down
Loading
Loading