Skip to content

Commit

Permalink
refactor Timestamp component / extract parseDate helper
Browse files Browse the repository at this point in the history
  • Loading branch information
kenkunz committed Sep 14, 2023
1 parent 812369e commit ba0ead8
Show file tree
Hide file tree
Showing 16 changed files with 146 additions and 133 deletions.
4 changes: 3 additions & 1 deletion src/lib/components/ContentTile.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ A `ctaLabel` or `cta` slot may also be provided to include an explicit button ta
<div class="content">
<div class="info">
{#if date}
<Timestamp {date} withRelative />
<Timestamp {date} let:parsedDate let:relative>
{parsedDate?.toDateString()}, {relative}
</Timestamp>
{/if}

{#if title}
Expand Down
99 changes: 39 additions & 60 deletions src/lib/components/Timestamp.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,81 +6,60 @@ by JS Date) or a Date object.
#### Usage:
```tsx
<Timestamp date={1672531200} format="iso" withTime />
<Timestamp date="2023-01-01T00:00Z" withRelative />
<Timestamp date={1672531200} withTime />
<Timestamp date="2023-01-01T00:00" relative />
<Timestamp date={someDate} let:parsedDate let:dateStr let:timeStr let:relative>
{dateStr} at {timmeStr}, {relative}
</Timestamp>
```
-->
<script lang="ts">
import { formatDistanceToNow } from 'date-fns';
import { type MaybeParsableDate, parseDate } from '$lib/helpers/date';
type MaybeParsableDate = Maybe<Date | string | number>;
export let date: MaybeParsableDate;
export let format: 'default' | 'relative' | 'iso' = 'default';
export let withRelative = false;
export let withTime = false;
export let relative = false;
export let withSeconds = false;
export let withTime = withSeconds;
$: parsedDate = parse(date);
$: isoString = parsedDate?.toISOString();
let isoStr: string, relativeStr: string, dateStr: string, timeStr: string;
const formatters = {
default: getDefaultDateString,
relative: getRelativeDateString,
iso: getISODateString
};
$: parsedDate = parseDate(date);
function parse(value: MaybeParsableDate) {
if (value instanceof Date) return value;
if (value === null || value === undefined) return undefined;
// numeric values (may come from server as string type)
let numVal = Number(value);
if (Number.isFinite(numVal)) return parseNumeric(numVal);
// string values
if (typeof value === 'string') return parseString(value);
}
function parseNumeric(value: number) {
// heuristic to determine is this is a Unix epoch value (no ms)
if (value < 10_000_000_000) value *= 1000;
return new Date(value);
}
function parseString(value: string) {
// only return valid parsed dates
const parsedDate = new Date(value);
return Number.isFinite(parsedDate.valueOf()) ? parsedDate : undefined;
}
function getDefaultDateString(d: Date) {
const parts = [d.toDateString()];
if (withRelative) parts.push(getRelativeDateString(d));
return parts.join(', ');
$: if (parsedDate) {
isoStr = parsedDate.toISOString();
dateStr = isoStr.slice(0, 10);
timeStr = isoStr.slice(11, withSeconds ? 19 : 16);
relativeStr = formatDistanceToNow(parsedDate, { addSuffix: true });
}
</script>

function getRelativeDateString(d: Date) {
return formatDistanceToNow(d, { addSuffix: true });
}
<time class="timestamp" datetime={isoStr}>
<slot {parsedDate} {dateStr} {timeStr} relative={relativeStr}>
{#if parsedDate}
{#if relative}
<span>{relativeStr}</span>
{:else}
<!-- ensure no whitespace between span and #if block! -->
<span>{dateStr}</span>{#if withTime}<span>{timeStr}</span>{/if}
{/if}
{:else}
---
{/if}
</slot>
</time>

function getISODateString(d: Date) {
const isoStr = d.toISOString();
let dateStr = `<span>${isoStr.slice(0, 10)}</span>`;
if (withTime || withSeconds) {
dateStr += ` <span>${isoStr.slice(11, withSeconds ? 19 : 16)}</span>`;
<style lang="postcss">
.timestamp {
:global(span) {
white-space: nowrap;
}
return dateStr;
}
</script>
{#if parsedDate}
<time class="timestamp" datetime={isoString}>{@html formatters[format](parsedDate)}</time>
{:else}
<slot>---</slot>
{/if}

<style lang="postcss">
.timestamp :global(span) {
white-space: nowrap;
/* re-inject space between sibling spans */
span + span {
&::before {
content: ' ';
}
}
</style>
61 changes: 9 additions & 52 deletions src/lib/components/Timestamp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,80 +10,37 @@ describe('Timestamp component', () => {
getByText('---');
});

test('should render human readable date string by default', () => {
const { getByText } = render(Timestamp, { date });
getByText('Sun Jan 01 2023');
});

test('should append relative value when requested', () => {
vi.useFakeTimers();
vi.setSystemTime('2023-01-15T12:00Z');

const { getByText } = render(Timestamp, { date, withRelative: true });
getByText('Sun Jan 01 2023, 14 days ago');

vi.useRealTimers();
});

test('should render iso date string', () => {
const { getByText, queryByText } = render(Timestamp, { date, format: 'iso' });
test('should render iso date string by default', () => {
const { getByText, queryByText } = render(Timestamp, { date });
getByText('2023-01-01');
expect(queryByText('12:00')).toBeNull();
});

test('should render iso date with time', () => {
const { container } = render(Timestamp, { date, format: 'iso', withTime: true });
const { container } = render(Timestamp, { date, withTime: true });
const timeEl = container.querySelector('time');
// getByText does not search across inline HTML tags; use toHaveTextContent instead
expect(timeEl).toHaveTextContent('2023-01-01 12:00');
// note: space between date and time is injected via CSS
expect(timeEl).toHaveTextContent('2023-01-0112:00');
});

test('should render iso date with time including seconds', () => {
const { container } = render(Timestamp, { date, format: 'iso', withSeconds: true });
const { container } = render(Timestamp, { date, withSeconds: true });
const timeEl = container.querySelector('time');
// getByText does not search across inline HTML tags; use toHaveTextContent instead
expect(timeEl).toHaveTextContent('2023-01-01 12:00:00');
// note: space between date and time is injected via CSS
expect(timeEl).toHaveTextContent('2023-01-0112:00:00');
});

test('should render relative date string', () => {
vi.useFakeTimers();
vi.setSystemTime('2023-01-15T12:00Z');

const timeEl = render(Timestamp, { date, format: 'relative' }).container.querySelector('time');
const timeEl = render(Timestamp, { date, relative: true }).container.querySelector('time');

expect(timeEl).toHaveAttribute('datetime', date.toISOString());
expect(timeEl).toHaveTextContent('14 days ago');

vi.useRealTimers();
});

test('should parse ISO date string', () => {
const { getByText } = render(Timestamp, { date: '2022-12-02T16:53Z', format: 'iso', withTime: true });
getByText('2022-12-02');
getByText('16:53');
});

test('should parse JS-style numeric date value', () => {
const { getByText } = render(Timestamp, { date: 1670000000000, format: 'iso', withTime: true });
getByText('2022-12-02');
getByText('16:53');
});

test('should parse JS-style numeric value provided as string', () => {
const { getByText } = render(Timestamp, { date: '1670000000000', format: 'iso', withTime: true });
getByText('2022-12-02');
getByText('16:53');
});

test('should parse unix epoch numeric value', () => {
const { getByText } = render(Timestamp, { date: 1670000000, format: 'iso', withTime: true });
getByText('2022-12-02');
getByText('16:53');
});

test('should parse unix epoch string value', () => {
const { getByText } = render(Timestamp, { date: '1670000000', format: 'iso', withTime: true });
getByText('2022-12-02');
getByText('16:53');
});
});
29 changes: 29 additions & 0 deletions src/lib/helpers/date.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { parseDate } from './date';

describe('parseDate', () => {
const expectedDate = new Date('2022-12-02T16:53Z');

test('should parse ISO date string', () => {
expect(parseDate('2022-12-02T16:53Z')).toEqual(expectedDate);
});

test('should parse ISO-like date string missing tz offset', () => {
expect(parseDate('2022-12-02T16:53')).toEqual(expectedDate);
});

test('should parse JS-style numeric date value', () => {
expect(parseDate(1669999980000)).toEqual(expectedDate);
});

test('should parse JS-style numeric value provided as string', () => {
expect(parseDate('1669999980000')).toEqual(expectedDate);
});

test('should parse unix epoch numeric value', () => {
expect(parseDate(1669999980)).toEqual(expectedDate);
});

test('should parse unix epoch string value', () => {
expect(parseDate(1669999980)).toEqual(expectedDate);
});
});
32 changes: 32 additions & 0 deletions src/lib/helpers/date.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
export type MaybeParsableDate = Maybe<Date | string | number>;

/**
* Try to parse a value as a date. Gracefully handles string values,
* Unix epoch values (no ms), JS style numeric values (with ms),
* and ISO-like date strings missing tz offset (e.g. "2023-01-01T12:00")
*/
export function parseDate(value: MaybeParsableDate) {
if (value instanceof Date) return value;
if (value === null || value === undefined) return undefined;

// numeric values (may come from server as string type)
let numVal = Number(value);
if (Number.isFinite(numVal)) {
// heuristic to determine is this is a Unix epoch value (no ms)
if (numVal < 10_000_000_000) numVal *= 1000;
return new Date(numVal);
}

// string values
if (typeof value === 'string') {
// check for ISO-like date strings missing tz offset, e.g. "2023-01-01T12:00:00"
// Append "Z" to ensure JS Date parser treats these as UTC
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(value)) {
value += 'Z';
}
const parsedDate = new Date(value);
// only return valid parsed dates
return Number.isFinite(parsedDate.valueOf()) ? parsedDate : undefined;
}
}

/**
* Return a date range (as two-date array) from a initial date and number of days.
* The time component is stripped from the original `date`. `days` may be positive
Expand Down
5 changes: 4 additions & 1 deletion src/routes/blog/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
<header>
<SocialLinks --justify-content="space-between" />
<h1>{post.title}</h1>
<Timestamp date={post.published_at} withRelative />
<Timestamp date={post.published_at} let:parsedDate let:relative>
{parsedDate?.toDateString()}, {relative}
</Timestamp>

<img src={post.feature_image} alt={post.feature_image_alt} />
</header>

Expand Down
2 changes: 1 addition & 1 deletion src/routes/strategies/ChartThumbnail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
<div class="chart-hover-info" style:--x="{position.cx}px" style:--y="{position.CloseY}px">
<UpDownCell value={data.Close - data.iqPrevClose}>
<div class="date">
<Timestamp date={data.DT} format="iso" />
<Timestamp date={data.DT} />
</div>
<div class="value">{formatPercent(data.Close)}</div>
</UpDownCell>
Expand Down
6 changes: 2 additions & 4 deletions src/routes/strategies/KeyMetricDescription.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,17 @@
<li>
The period used for the backtest simulation is
<span class="timespan">
<Timestamp date={metric.calculation_window_start_at} format="iso" />—<Timestamp
<Timestamp date={metric.calculation_window_start_at} />—<Timestamp
date={metric.calculation_window_end_at}
format="iso"
/></span
>.
</li>
{:else if metric?.calculation_method == 'historical_data'}
<li>
The calculation period for live trading is
<span class="timespan">
<Timestamp date={metric.calculation_window_start_at} format="iso" />—<Timestamp
<Timestamp date={metric.calculation_window_start_at} />—<Timestamp
date={metric.calculation_window_end_at}
format="iso"
/></span
>.
</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,19 @@
header: 'Opened',
id: 'opened_at',
accessor: ({ opened_at }) => toISODate(opened_at),
cell: ({ value }) => createRender(Timestamp, { date: value, format: 'iso', withTime: true })
cell: ({ value }) => createRender(Timestamp, { date: value, withTime: true })
}),
table.column({
header: 'Closed',
id: 'closed_at',
accessor: ({ closed_at }) => toISODate(closed_at),
cell: ({ value }) => createRender(Timestamp, { date: value, format: 'iso', withTime: true })
cell: ({ value }) => createRender(Timestamp, { date: value, withTime: true })
}),
table.column({
header: 'Frozen at',
id: 'frozen_at',
accessor: ({ frozen_at }) => toISODate(frozen_at),
cell: ({ value }) => createRender(Timestamp, { date: value, format: 'iso', withTime: true })
cell: ({ value }) => createRender(Timestamp, { date: value, withTime: true })
}),
table.column({
header: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</script>

<div class="log-entry level--{level}">
<Timestamp date={timestamp} format="iso" withSeconds />
<Timestamp date={timestamp} withSeconds />
<span class="message">
{message}
</span>
Expand Down
6 changes: 3 additions & 3 deletions src/routes/strategies/[strategy]/(nav)/status/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@
<div class="inner">
<SummaryBox title="Current session" subtitle="Statistics since the trade executor instance was restarted">
<DataBox label="Restarted" size="xs">
<Timestamp date={runState.started_at} format="relative" />
<Timestamp date={runState.started_at} relative />
</DataBox>
<DataBox label="Status last updated" size="xs">
<Timestamp date={runState.last_refreshed_at} format="relative" />
<Timestamp date={runState.last_refreshed_at} relative />
</DataBox>
<DataBox label="Trading cycles" value={runState.cycles} size="xs" />
<DataBox label="Take profit/stop loss checks" value={runState.position_trigger_checks} size="xs" />
Expand All @@ -41,7 +41,7 @@
<SummaryBox title="Lifetime" subtitle="Overall execution metrics">
<DataBox label="Completed trading cycles" value={`${(state.cycle || 0) - 1}`} size="xs" />
<DataBox label="First started" size="xs">
<Timestamp date={state.created_at} format="iso" withTime />
<Timestamp date={state.created_at} withTime />
</DataBox>
</SummaryBox>
</div>
Expand Down
Loading

0 comments on commit ba0ead8

Please sign in to comment.