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
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ jobs:
- name: Install dependencies
run: npm ci

# Not a test, but cheap and with nothing else to gate it: a stray `Date` passes every
# test that does not happen to compare two timestamps as strings.
- name: Check Temporal is the only date API
run: npm run check:temporal

# Split by OS rather than run both: `test:coverage` is `vitest run --coverage`, the
# same fourteen files end to end, so ubuntu was running the whole suite twice.
- name: Run tests
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ popover, not for the person who wrote the commit.

## [Unreleased]

## [1.9.1] - 2026-10-01

### Fixed
- **An Enterprise-billed account no longer syncs on its own, without an assigned seat.**
The Enterprise tier used to turn on Cloud Sync by itself, so the account a subscription is
billed to - the owner - got full access before ever assigning themselves one of their own
seats. Cloud Sync now needs a Pro subscription or an actually assigned Enterprise seat, and
an owner gets one the same way a teammate does
- **An export's suggested file name carries the right date around midnight.** The name took
its date from UTC and its time from your own clock, so an export made in the hours between
your midnight and UTC's was named with the day before or the day after. Both now come from
your own calendar

## [1.9.0] - 2026-09-27

### Added
Expand Down
9 changes: 8 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ mod tests {
content: "test content".to_string(),
attachments: vec![],
files: vec![],
created_at: chrono::Utc::now().to_rfc3339(),
created_at: crate::time::now_iso(),
context_id: "default".to_string(),
completed: false,
completed_at: None,
Expand Down Expand Up @@ -363,6 +363,13 @@ jobs:
expect(formattedDate).toContain('2025');
```

Fix "now" with `setClock` from `$lib/utils/time`, and restore it with `setClock(null)`
in `afterEach`. All app code reads the clock through that module, never `Date` -
`npm run check:temporal` fails on a `Date` anywhere, tests included. Build timestamps
with `fromEpochMs` or `toCanonical` so they have the same nine-digit form the app
writes. `vi.useFakeTimers()` still moves the clock as well, because the polyfill
reads the time through it.

2. **File API Mocking**: jsdom doesn't fully support File APIs
```typescript
// Mock arrayBuffer for File objects in tests
Expand Down
29 changes: 26 additions & 3 deletions package-lock.json

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

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stashpad",
"version": "1.9.0",
"version": "1.9.1",
"description": "The staging area for your AI context.",
"author": {
"name": "Nico Wiedemann",
Expand All @@ -25,6 +25,7 @@
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
"check:changelog": "node scripts/check-changelog.mjs",
"check:temporal": "node scripts/check-temporal.mjs",
"predev": "npm run sync-version",
"dev": "tauri dev",
"vite:dev": "vite",
Expand Down Expand Up @@ -85,6 +86,7 @@
"marked-highlight": "^2.2.3",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.0",
"temporal-polyfill": "^1.0.5"
}
}
4 changes: 2 additions & 2 deletions screenshots/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,8 @@ async function startDevServer() {
child.stderr.on('data', (d) => process.stderr.write(d));

// Cold, Vite spends most of a minute pre-bundling before it answers.
const deadline = Date.now() + 180_000;
while (Date.now() < deadline) {
const deadline = performance.now() + 180_000;
while (performance.now() < deadline) {
if (await demoPageIsUp()) return child;
if (child.exitCode !== null) throw new Error(`the dev server exited (${child.exitCode})`);
await new Promise((r) => setTimeout(r, 500));
Expand Down
24 changes: 7 additions & 17 deletions screenshots/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,16 @@ installMockBackend({ settings });
/**
* Freeze the clock at the fixtures' instant.
*
* Relative timestamps ("6m ago") are computed against `Date.now()`, so without this the
* same scene captured twice produces two different images and every release ships a
* screenshot diff that means nothing. Only the reading of "now" is frozen - timers still
* run, or the app would never finish starting.
* Relative timestamps ("6m ago") are computed against the current time, so without this
* the same scene captured twice produces two different images and every release ships a
* screenshot diff that means nothing. Every reading of "now" in the app goes through
* `$lib/utils/time`, so fixing its clock is enough. Timers still run, or the app would
* never finish starting.
*/
async function freezeClock(): Promise<void> {
const { NOW } = await import('./fixtures');
const RealDate = Date;
const fixed = NOW.getTime();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Frozen: any = function (this: unknown, ...args: unknown[]) {
return args.length === 0
? new RealDate(fixed)
: new (RealDate as unknown as new (...a: unknown[]) => Date)(...args);
};
Frozen.prototype = RealDate.prototype;
Frozen.now = () => fixed;
Frozen.parse = RealDate.parse;
Frozen.UTC = RealDate.UTC;
globalThis.Date = Frozen;
const { setClock } = await import('../src/lib/utils/time');
setClock(() => NOW);
}

await freezeClock();
Expand Down
9 changes: 5 additions & 4 deletions screenshots/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,20 @@
* fixture: these end up on the public website, and a screenshot is the easiest place
* to leak a customer name or a home directory without noticing.
*
* Timestamps are relative to a fixed instant (`NOW`) rather than to `Date.now()`, so a
* Timestamps are relative to a fixed instant (`NOW`) rather than to the real clock, so a
* capture taken today and one taken next month produce the same images. The app renders
* "2h ago" style labels from these, which would otherwise churn every release.
*/

import type { Attachment, Context, Settings, StashItem } from '../src/lib/types';
import { Temporal, toCanonical } from '../src/lib/utils/time';

/** The instant the fixtures pretend it is. */
export const NOW = new Date('2026-01-15T14:30:00.000Z');
export const NOW = Temporal.Instant.from('2026-01-15T14:30:00.000Z');

/** An ISO timestamp `minutes` before `NOW`. */
function ago(minutes: number): string {
return new Date(NOW.getTime() - minutes * 60_000).toISOString();
return toCanonical(NOW.subtract({ minutes }));
}

/**
Expand Down Expand Up @@ -209,7 +210,7 @@ export const settings: Settings = {
email: 'dev@example.com',
subscriptionTier: 'pro',
subscriptionStatus: 'active',
subscriptionPeriodEnd: new Date(NOW.getTime() + 21 * 86_400_000).toISOString(),
subscriptionPeriodEnd: toCanonical(NOW.add({ hours: 21 * 24 })),
lastSyncAt: ago(2),
},
};
Expand Down
7 changes: 4 additions & 3 deletions screenshots/mock-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type { InvokeArgs } from '@tauri-apps/api/core';
import { mockIPC, mockWindows } from '@tauri-apps/api/mocks';
import type { Attachment, Context, Settings, StashItem } from '../src/lib/types';
import * as fixtures from './fixtures';
import { toCanonical } from '../src/lib/utils/time';

/** Commands whose answer is "nothing happened, carry on". */
const NO_OP = new Set([
Expand Down Expand Up @@ -172,15 +173,15 @@ export function installMockBackend(overrides: Partial<DemoState> = {}): void {
case 'sync_stashes_api':
return {
synced: [],
serverTime: fixtures.NOW.toISOString(),
serverTime: toCanonical(fixtures.NOW),
rejected: [],
partial: true,
positions: [],
};
case 'sync_contexts_api':
return {
synced: [],
serverTime: fixtures.NOW.toISOString(),
serverTime: toCanonical(fixtures.NOW),
rejected: [],
};
case 'upload_attachment_to_cloud':
Expand Down Expand Up @@ -263,7 +264,7 @@ export function installMockBackend(overrides: Partial<DemoState> = {}): void {
fileName,
fileSize: 2_048,
mimeType: 'text/plain',
createdAt: fixtures.NOW.toISOString(),
createdAt: toCanonical(fixtures.NOW),
};
}
}
97 changes: 97 additions & 0 deletions scripts/check-temporal.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: AGPL-3.0-only

// Copyright (C) 2026 Nico Wiedemann
//
// This file is part of Stashpad.
// Stashpad is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License, version 3,
// as published by the Free Software Foundation.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
// See the GNU Affero General Public License for more details.

// Fails when anything in the tracked source reaches for an old date API instead of
// Temporal.
//
// Every date and time in the frontend goes through `src/lib/utils/time.ts`, which takes
// Temporal from the polyfill as a value, and every one in the backend through
// `src-tauri/src/time.rs`, on `temporal_rs`. Three things break that, and none of them
// shows up as an error:
//
// * A `Date` slips back in. It parses zoneless strings as local time in some engines and
// rejects them in others, and its `toISOString()` writes a different width than the
// backend, so two timestamps that look comparable as strings no longer are.
// * Something imports the polyfill on its own, or uses a global `Temporal`. The app would
// then run native Temporal on Windows and the polyfill on macOS and Linux.
// * `chrono` or `SystemTime` comes back in the backend, with its own idea of the format.
//
// Run with `npm run check:temporal`. CI runs it with the frontend tests.
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

const TIME_MODULE = 'src/lib/utils/time.ts';
const RUST_TIME_MODULE = 'src-tauri/src/time.rs';

const RULES = [
{
what: '`Date` - use the helpers in src/lib/utils/time.ts',
files: /\.(ts|js|mjs|svelte)$/,
pattern: /\bnew Date\b|\bDate\.[A-Za-z]|\binstanceof Date\b|[:<|,(]\s*Date\b(?!\s*:)|\bglobalThis\.Date\b/,
},
{
what: 'the Temporal polyfill imported outside src/lib/utils/time.ts',
files: /\.(ts|js|mjs|svelte)$/,
pattern: /["'](temporal-polyfill|@js-temporal\/polyfill)(\/[^"']*)?["']/,
allow: TIME_MODULE,
},
{
what: 'a global Temporal - import it from src/lib/utils/time.ts',
files: /\.(ts|js|mjs|svelte)$/,
pattern: /\bglobalThis\.Temporal\b/,
},
{
what: '`chrono` - use src-tauri/src/time.rs',
files: /\.rs$|Cargo\.toml$/,
pattern: /\bchrono\b/,
},
{
what: '`SystemTime` - use src-tauri/src/time.rs',
files: /\.rs$/,
pattern: /\bSystemTime\b|\bUNIX_EPOCH\b/,
allow: RUST_TIME_MODULE,
},
];

/** Comment lines, which may name what was replaced. */
const COMMENT = /^\s*(\/\/|\*|\/\*|<!--|#)/;

const files = execFileSync(
'git',
['ls-files', '--cached', '--others', '--exclude-standard', '*.ts', '*.js', '*.mjs', '*.svelte', '*.rs', 'Cargo.toml'],
{ encoding: 'utf8' },
)
.split('\n')
.filter((f) => f && f !== 'scripts/check-temporal.mjs');

const findings = [];
for (const file of files) {
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
if (COMMENT.test(line)) return;
const code = line.replace(/\s(\/\/|#).*$/, '');
for (const rule of RULES) {
if (rule.allow === file || !rule.files.test(file)) continue;
if (rule.pattern.test(code)) {
findings.push(` ${file}:${i + 1}: ${rule.what}\n ${line.trim()}`);
}
}
});
}

if (findings.length > 0) {
console.error(`Found ${findings.length} use(s) of the old date APIs:\n`);
console.error(findings.join('\n'));
process.exit(1);
}
console.log(`check:temporal - ${files.length} files, no Date, chrono or SystemTime`);
Loading
Loading