feat(web/m7-365): HistoryRow component with click-to-play
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HistoryEvent } from '$lib/api/history';
|
||||||
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
|
import { coverUrl } from '$lib/media/covers';
|
||||||
|
|
||||||
|
let { event } = $props<{ event: HistoryEvent }>();
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
playQueue([event.track], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format played_at as a human-readable timestamp.
|
||||||
|
// Bands: <1h "23m ago" / <24h "3h ago" / <7d "Tue 14:32" /
|
||||||
|
// older same-year "May 1" / older diff-year "May 1, 2025".
|
||||||
|
function relativeTime(playedAtIso: string): string {
|
||||||
|
const playedAt = new Date(playedAtIso);
|
||||||
|
const now = new Date();
|
||||||
|
const ms = now.getTime() - playedAt.getTime();
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
if (minutes < 60) return `${Math.max(1, minutes)}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) {
|
||||||
|
const dayOfWeek = playedAt.toLocaleDateString(undefined, { weekday: 'short' });
|
||||||
|
const time = playedAt.toLocaleTimeString(undefined, {
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false
|
||||||
|
});
|
||||||
|
return `${dayOfWeek} ${time}`;
|
||||||
|
}
|
||||||
|
if (playedAt.getFullYear() === now.getFullYear()) {
|
||||||
|
return playedAt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
return playedAt.toLocaleDateString(undefined, {
|
||||||
|
month: 'short', day: 'numeric', year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = $derived(relativeTime(event.played_at));
|
||||||
|
const tooltip = $derived(new Date(event.played_at).toISOString());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleClick}
|
||||||
|
aria-label={`Play ${event.track.title}`}
|
||||||
|
class="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={coverUrl(event.track.album_id)}
|
||||||
|
alt=""
|
||||||
|
class="h-11 w-11 flex-shrink-0 rounded object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-sm font-medium text-text-primary">{event.track.title}</div>
|
||||||
|
<div class="truncate text-xs text-text-secondary">
|
||||||
|
{event.track.artist_name} · {event.track.album_title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span title={tooltip} class="flex-shrink-0 text-xs text-text-secondary">
|
||||||
|
{timestamp}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import HistoryRow from './HistoryRow.svelte';
|
||||||
|
import type { HistoryEvent } from '$lib/api/history';
|
||||||
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
|
const playQueue = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('$lib/player/store.svelte', () => ({
|
||||||
|
playQueue: (...args: unknown[]) => playQueue(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sampleTrack: TrackRef = {
|
||||||
|
id: 'track-1',
|
||||||
|
title: 'Song Title',
|
||||||
|
album_id: 'album-1',
|
||||||
|
album_title: 'Album',
|
||||||
|
artist_id: 'artist-1',
|
||||||
|
artist_name: 'Artist Name',
|
||||||
|
duration_sec: 200,
|
||||||
|
stream_url: '/stream/track-1'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleEvent: HistoryEvent = {
|
||||||
|
id: 'event-1',
|
||||||
|
played_at: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||||||
|
track: sampleTrack
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('HistoryRow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
playQueue.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders track title, artist, and album', () => {
|
||||||
|
render(HistoryRow, { props: { event: sampleEvent } });
|
||||||
|
expect(screen.getByText('Song Title')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Artist Name/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Album/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the row calls playQueue([track], 0)', async () => {
|
||||||
|
render(HistoryRow, { props: { event: sampleEvent } });
|
||||||
|
const row = screen.getByRole('button', { name: /play song title/i });
|
||||||
|
await fireEvent.click(row);
|
||||||
|
expect(playQueue).toHaveBeenCalledWith([sampleTrack], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a relative timestamp for events under 1 hour ago', () => {
|
||||||
|
render(HistoryRow, { props: { event: sampleEvent } });
|
||||||
|
expect(screen.getByText(/\d+m ago/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user