feat(frontend): add types + format helpers with tdd
This commit is contained in:
parent
3b8400b2ed
commit
a1e24c6a81
5 changed files with 150 additions and 0 deletions
28
frontend/src/lib/format.ts
Normal file
28
frontend/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export function fmtMs(seconds: number): string {
|
||||
const s = Math.max(0, Math.floor(seconds));
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function fmtRelative(d: Date, now: Date = new Date()): string {
|
||||
const ms = now.getTime() - d.getTime();
|
||||
if (ms < 5_000) return 'now';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
const days = Math.floor(h / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
export function fmtJpDate(d: Date): string {
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
}
|
||||
|
||||
export function fmtJpTime(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
41
frontend/src/lib/types.ts
Normal file
41
frontend/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export interface Station {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
tags: string[];
|
||||
mounts: { mp3: string; opus: string };
|
||||
cover: string | null;
|
||||
}
|
||||
|
||||
export interface NowPlaying {
|
||||
station: string;
|
||||
artist: string;
|
||||
title: string;
|
||||
album: string;
|
||||
filename: string;
|
||||
duration: string;
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
filename: string;
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
export interface IcecastSourceJson {
|
||||
listenurl: string;
|
||||
server_name?: string;
|
||||
server_type?: string;
|
||||
listeners?: number;
|
||||
}
|
||||
|
||||
export interface IcecastStatusJson {
|
||||
icestats: {
|
||||
host?: string;
|
||||
source?: IcecastSourceJson | IcecastSourceJson[];
|
||||
};
|
||||
}
|
||||
61
frontend/src/tests/format.test.ts
Normal file
61
frontend/src/tests/format.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { fmtMs, fmtRelative, fmtJpDate, fmtJpTime } from '@lib/format';
|
||||
|
||||
describe('fmtMs', () => {
|
||||
it('formats zero as 0:00', () => {
|
||||
expect(fmtMs(0)).toBe('0:00');
|
||||
});
|
||||
it('pads seconds to two digits', () => {
|
||||
expect(fmtMs(7)).toBe('0:07');
|
||||
});
|
||||
it('formats 215 as 3:35', () => {
|
||||
expect(fmtMs(215)).toBe('3:35');
|
||||
});
|
||||
it('handles minutes >= 10 correctly', () => {
|
||||
expect(fmtMs(605)).toBe('10:05');
|
||||
});
|
||||
it('clamps negative input to 0:00', () => {
|
||||
expect(fmtMs(-3)).toBe('0:00');
|
||||
});
|
||||
it('floors fractional seconds', () => {
|
||||
expect(fmtMs(7.9)).toBe('0:07');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtRelative', () => {
|
||||
const now = new Date('2026-04-30T12:00:00Z');
|
||||
it('formats <60s as Xs', () => {
|
||||
expect(fmtRelative(new Date('2026-04-30T11:59:30Z'), now)).toBe('30s');
|
||||
});
|
||||
it('formats <1h as Xm', () => {
|
||||
expect(fmtRelative(new Date('2026-04-30T11:55:00Z'), now)).toBe('5m');
|
||||
});
|
||||
it('formats <24h as Xh', () => {
|
||||
expect(fmtRelative(new Date('2026-04-30T09:00:00Z'), now)).toBe('3h');
|
||||
});
|
||||
it('formats >=24h as Xd', () => {
|
||||
expect(fmtRelative(new Date('2026-04-28T12:00:00Z'), now)).toBe('2d');
|
||||
});
|
||||
it('returns "now" for the current moment', () => {
|
||||
expect(fmtRelative(new Date('2026-04-30T12:00:00Z'), now)).toBe('now');
|
||||
});
|
||||
it('returns "now" for a future timestamp (clock skew)', () => {
|
||||
expect(fmtRelative(new Date('2026-04-30T12:00:05Z'), now)).toBe('now');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtJpDate', () => {
|
||||
it('formats with full kanji', () => {
|
||||
// build the date in local time so the test isn't tz-sensitive
|
||||
const d = new Date(2026, 3, 30); // month is 0-indexed → April
|
||||
expect(fmtJpDate(d)).toBe('2026年4月30日');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtJpTime', () => {
|
||||
it('formats HH:MM:SS', () => {
|
||||
const d = new Date();
|
||||
d.setHours(7); d.setMinutes(3); d.setSeconds(9);
|
||||
expect(fmtJpTime(d)).toBe('07:03:09');
|
||||
});
|
||||
});
|
||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@lib': resolve(__dirname, 'src/lib'),
|
||||
'@components': resolve(__dirname, 'src/components'),
|
||||
'@styles': resolve(__dirname, 'src/styles'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
|
|
@ -6,4 +7,11 @@ export default defineConfig({
|
|||
include: ['src/tests/**/*.test.ts', 'src/tests/**/*.test.tsx'],
|
||||
globals: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@lib': resolve(__dirname, 'src/lib'),
|
||||
'@components': resolve(__dirname, 'src/components'),
|
||||
'@styles': resolve(__dirname, 'src/styles'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue