From a1e24c6a81e3ba512f593f501ffcceec6ffd3360 Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 09:29:52 +0600 Subject: [PATCH] feat(frontend): add types + format helpers with tdd --- frontend/src/lib/format.ts | 28 ++++++++++++++ frontend/src/lib/types.ts | 41 +++++++++++++++++++++ frontend/src/tests/format.test.ts | 61 +++++++++++++++++++++++++++++++ frontend/vite.config.ts | 12 ++++++ frontend/vitest.config.ts | 8 ++++ 5 files changed, 150 insertions(+) create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/tests/format.test.ts create mode 100644 frontend/vite.config.ts diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..0763a65 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -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())}`; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..055848f --- /dev/null +++ b/frontend/src/lib/types.ts @@ -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[]; + }; +} diff --git a/frontend/src/tests/format.test.ts b/frontend/src/tests/format.test.ts new file mode 100644 index 0000000..09f83fb --- /dev/null +++ b/frontend/src/tests/format.test.ts @@ -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'); + }); +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4d243cd --- /dev/null +++ b/frontend/vite.config.ts @@ -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'), + }, + }, +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 34b2da7..d0fb896 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -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'), + }, + }, });