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
65 changes: 65 additions & 0 deletions e2e/schedule.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { test, expect } from '@playwright/test';

test.describe('Schedule UI', () => {
test.beforeEach(async ({ page }) => {
// Start with a clean store so the empty-state and create flow are deterministic.
await page.goto('/schedule');
await page.evaluate(() => {
window.localStorage.removeItem('wraith-schedule-storage');
});
await page.goto('/schedule');
});

test('renders the page heading and the empty state', async ({ page }) => {
await expect(page.getByRole('heading', { name: /schedule/i, level: 1 })).toBeVisible();
await expect(page.getByText('No active schedules')).toBeVisible();
});

test('creates, pauses, resumes a schedule and persists it across reload', async ({ page }) => {
await page.getByLabel('Recipient').fill('st:xlm:test-recipient');
await page.getByLabel('Amount').fill('5');
await page.getByLabel('Interval').selectOption('daily');
await page.getByRole('button', { name: 'Add schedule' }).click();

const row = page.getByTestId('schedule-row');
await expect(row).toHaveCount(1);
await expect(row).toContainText('st:xlm:test-recipient');
await expect(row).toContainText('5 XLM');
await expect(row.getByTestId('status-active')).toBeVisible();

await row.getByRole('button', { name: 'Pause' }).click();
await expect(row.getByTestId('status-paused')).toBeVisible();
await expect(row.getByRole('button', { name: 'Resume' })).toBeVisible();

await row.getByRole('button', { name: 'Resume' }).click();
await expect(row.getByTestId('status-active')).toBeVisible();

await page.reload();

const persistedRow = page.getByTestId('schedule-row');
await expect(persistedRow).toHaveCount(1);
await expect(persistedRow).toContainText('st:xlm:test-recipient');
await expect(persistedRow.getByTestId('status-active')).toBeVisible();
});

test('cancelling a schedule removes it from the active list', async ({ page }) => {
await page.getByLabel('Recipient').fill('st:xlm:another-recipient');
await page.getByLabel('Amount').fill('2');
await page.getByRole('button', { name: 'Add schedule' }).click();

const row = page.getByTestId('schedule-row');
await expect(row).toHaveCount(1);

await row.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByText('No active schedules')).toBeVisible();
await expect(page.getByTestId('schedule-row')).toHaveCount(0);
});

test('rejects an empty amount with an inline error', async ({ page }) => {
await page.getByLabel('Recipient').fill('st:xlm:test-recipient');
await page.getByRole('button', { name: 'Add schedule' }).click();

await expect(page.getByRole('alert')).toContainText('Amount must be greater than zero.');
await expect(page.getByTestId('schedule-row')).toHaveCount(0);
});
});
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
"format:check": "prettier --check .",
"prepare": "husky",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
"test:e2e:ui": "playwright test --ui",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:unit": "vitest run",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test-storybook": "test-storybook"
Expand Down Expand Up @@ -54,7 +55,6 @@
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"@playwright/test": "^1.61.0",
"@types/node": "^26.0.0",
"@storybook/addon-a11y": "^8",
"@storybook/addon-essentials": "^8",
"@storybook/addon-interactions": "^8",
Expand All @@ -63,7 +63,7 @@
"@storybook/test": "^8.6.18",
"@storybook/test-runner": "^0.19.1",
"@types/jest-image-snapshot": "^6.4.1",
"@types/node": "^25.9.1",
"@types/node": "^26.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.5.0",
Expand Down
1,020 changes: 597 additions & 423 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

12 changes: 0 additions & 12 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,3 @@ onlyBuiltDependencies:
- esbuild
- keccak
- utf-8-validate
- utf-8-validate@5.0.10
- utf-8-validate@6.0.6

allowBuilds:
'@swc/core': set this to true or false
bufferutil: true
esbuild: true
keccak: true
msw: set this to true or false
utf-8-validate: true

dangerouslyAllowAllBuilds: true
4 changes: 3 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { HelpButton } from '@/components/HelpButton';
import Send from '@/pages/Send';
import Receive from '@/pages/Receive';
import Vault from '@/pages/Vault';
import Schedule from '@/pages/Schedule';

export function App() {
return (
Expand All @@ -22,11 +23,12 @@ export function App() {
<Route path="/receive" element={<Receive />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/vault" element={<Vault />} />
<Route path="/schedule" element={<Schedule />} />
<Route path="/pay" element={<Send />} />
<Route path="*" element={<Navigate to="/send" replace />} />
</Routes>
</main>
<HelpButton />
</div>
);
}
}
1 change: 1 addition & 0 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function Header() {
const navLinks = [
{ to: '/send', label: t('nav.send') },
{ to: '/receive', label: t('nav.receive') },
{ to: '/schedule', label: t('nav.schedule') },
];

return (
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"nav": {
"send": "Send",
"receive": "Receive"
"receive": "Receive",
"schedule": "Schedule"
},
"header": {
"menuLabel": "Menu"
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/es.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"nav": {
"send": "Enviar",
"receive": "Recibir"
"receive": "Recibir",
"schedule": "Programar"
},
"header": {
"menuLabel": "Menú"
Expand Down
83 changes: 83 additions & 0 deletions src/lib/schedule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { nextRunAt, type Schedule } from './schedule';

function base(overrides: Partial<Schedule> = {}): Schedule {
return {
id: 'sched-1',
recipient: 'st:xlm:placeholder',
amount: '1',
asset: 'XLM',
interval: 'daily',
createdAt: 1_000_000,
endAt: undefined,
status: 'active',
runCount: 0,
lastRunAt: null,
...overrides,
};
}

const DAY = 24 * 60 * 60 * 1000;
const WEEK = 7 * DAY;

describe('nextRunAt', () => {
it('returns the first run one interval after createdAt when the schedule has not run yet', () => {
const schedule = base({ createdAt: 1_000_000, lastRunAt: null });
expect(nextRunAt(schedule, 1_000_001)).toBe(1_000_000 + DAY);
});

it('returns the createdAt timestamp itself when it is still in the future', () => {
const schedule = base({ createdAt: 5_000_000, lastRunAt: null });
expect(nextRunAt(schedule, 4_000_000)).toBe(5_000_000);
});

it('skips ahead by whole intervals to land strictly after now', () => {
const schedule = base({ createdAt: 0, lastRunAt: null, interval: 'daily' });
// Three full days have passed since the anchor.
expect(nextRunAt(schedule, DAY * 3 + 1)).toBe(DAY * 4);
});

it('advances from lastRunAt rather than createdAt once the schedule has fired', () => {
const schedule = base({
createdAt: 0,
lastRunAt: WEEK,
interval: 'weekly',
});
expect(nextRunAt(schedule, WEEK + 1)).toBe(WEEK * 2);
});

it('returns null when status is cancelled', () => {
expect(nextRunAt(base({ status: 'cancelled' }), 1_000_000)).toBeNull();
});

it('returns null when the next run would fall past endAt', () => {
const schedule = base({
createdAt: 0,
lastRunAt: null,
interval: 'daily',
endAt: DAY / 2,
});
expect(nextRunAt(schedule, 1)).toBeNull();
});

it('still returns a next-run time for paused schedules so the UI can show it', () => {
const schedule = base({ status: 'paused' });
expect(nextRunAt(schedule, 1_000_001)).toBe(1_000_000 + DAY);
});

it('advances monthly schedules by calendar month, respecting month-length edge cases', () => {
const jan31 = Date.UTC(2026, 0, 31);
const feb15 = Date.UTC(2026, 1, 15);
const schedule = base({
createdAt: jan31,
lastRunAt: null,
interval: 'monthly',
});
const next = nextRunAt(schedule, feb15);
// Calendar arithmetic: Jan 31 + 1 month lands on Feb 28 (or Mar 3 depending
// on the JS Date overflow rule), which is what we want documented here.
const expected = new Date(jan31);
expected.setMonth(expected.getMonth() + 1);
expect(next).toBe(expected.getTime());
});
});
83 changes: 83 additions & 0 deletions src/lib/schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
export type ScheduleInterval = 'daily' | 'weekly' | 'monthly';

export type ScheduleStatus = 'active' | 'paused' | 'cancelled';

export interface Schedule {
/** Stable id, generated client-side. */
id: string;
/** Stealth meta-address of the recipient (or a placeholder address for the demo). */
recipient: string;
/** Amount as the user typed it, kept as a string to avoid float quirks. */
amount: string;
/** Asset code, e.g. "XLM" or "USDC". */
asset: string;
interval: ScheduleInterval;
/** Unix ms when this schedule was created. Anchors the recurrence. */
createdAt: number;
/** Optional Unix ms; the schedule stops firing once now passes this. */
endAt?: number;
status: ScheduleStatus;
/** Number of mock executions recorded so far. */
runCount: number;
/** Unix ms of the last mock execution, or null if none yet. */
lastRunAt: number | null;
}

const DAY_MS = 24 * 60 * 60 * 1000;
const WEEK_MS = 7 * DAY_MS;

/**
* Returns the next time the schedule should fire, in Unix ms. The result is
* derived purely from the schedule's fields and the supplied `now`, so the
* function is trivially testable and deterministic. Returns `null` when the
* schedule has been cancelled or has run past its `endAt`.
*
* - `paused` schedules still have a next-run timestamp; the UI uses it to
* show the resume point, but the executor skips them.
* - `cancelled` schedules return `null`.
* - Monthly cadence advances by calendar month using the local Date object so
* month-end edge cases (e.g. Jan 31 -> Feb 28) follow the platform's own
* rules rather than a hand-rolled approximation.
*/
export function nextRunAt(
schedule: Pick<Schedule, 'interval' | 'createdAt' | 'lastRunAt' | 'endAt' | 'status'>,
now: number,
): number | null {
if (schedule.status === 'cancelled') return null;

const anchor = schedule.lastRunAt ?? schedule.createdAt;
let next: number;

switch (schedule.interval) {
case 'daily':
next = advanceFixed(anchor, now, DAY_MS);
break;
case 'weekly':
next = advanceFixed(anchor, now, WEEK_MS);
break;
case 'monthly':
next = advanceMonthly(anchor, now);
break;
}

if (schedule.endAt !== undefined && next > schedule.endAt) {
return null;
}
return next;
}

function advanceFixed(anchor: number, now: number, stepMs: number): number {
if (anchor > now) return anchor;
const elapsed = now - anchor;
const stepsToSkip = Math.floor(elapsed / stepMs) + 1;
return anchor + stepsToSkip * stepMs;
}

function advanceMonthly(anchor: number, now: number): number {
if (anchor > now) return anchor;
const date = new Date(anchor);
while (date.getTime() <= now) {
date.setMonth(date.getMonth() + 1);
}
return date.getTime();
}
Loading