diff --git a/CHANGELOG.md b/CHANGELOG.md index 825f1699..43294dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [hitstar] Show current game mode (Classic / Range) as small grey label top-left, only visible during an active game (hidden in the main menu). + +### Changed + +- [hitstar] Remove Spotify embed (full iframe) from the reveal phase; only round tracker, abort button, flipped track card and next-round button are shown after guessing. +- [hitstar] Spotify track search now uses a single random year (1955–2025) per request instead of a fixed range to improve year distribution across decades. +- [hitstar] Add `genre:pop` filter to Spotify search query to increase the share of well-known tracks. + +- [backend] Auto-seed toggles on application startup: missing toggles are created with default values from config; existing toggles are never overwritten. +- [hitstar] Add animated step-card tutorial in the menu that loops through 5 game steps with icons and auto-highlights each card every 2 seconds. +- [hitstar] Multi-mode architecture with Carbon Tabs (horizontal, compact, centered, no dropdown on any viewport) with spacing below for mode selection on the Hitstar start page. +- [hitstar] New "Range" game mode: set a 1–5 year range instead of guessing exactly; narrower ranges earn more points (max 50). +- [hitstar] Replace Spotify iframe embed in guessing phase with Spotify iFrame API: hidden iframe controlled by custom Play/Pause and Replay buttons for a clean, maskless player UI. + +### Fixed + +- [hitstar] Clicking a game mode tab no longer auto-starts a round; the menu always stays visible after page load or tab switch, and only "New Game" triggers the game. +- [hitstar] Album cover on the track card is now smaller on mobile viewports so all metadata (title, artist, album, year) fits within the card bounds without being clipped. +- [hitstar] Spotify iFrame API player no longer throws PlaybackError after the 30-second preview ends; clicking Play or Replay reloads the track via `loadUri` and starts playback automatically. +- [hitstar] Range mode NumberInput fields now correctly display the default value (2000) by overriding Carbon's oversized `padding-right` that was truncating the 4-digit year. +- [hitstar] CSS selector for Range submit button narrowed from `:global(button)` to `:global(button.bx--btn)` to prevent accidentally resizing the NumberInput stepper buttons. + +### Changed + +- [hitstar] Refactored Hitstar into shell + separate mode components (HitstarClassic, HitstarRange) with shared sub-components (HitstarRoundTracker, HitstarGameTopBar, HitstarLoadingScreen, HitstarResultsScreen). +- [hitstar] Refactored localStorage keys to mode-specific pattern (hitstar.classic.*, hitstar.range.*) with automatic migration of existing data. +- [hitstar] Hide best-score tile in menu when no score has been recorded yet. +- [hitstar] Replace static ProgressIndicator with custom icon step-cards (Headphones, Calendar, CheckmarkFilled, Repeat, Trophy) for the game tutorial. - [hitstar] New "Hitstar" music year guessing game: listen to a 30-second preview and guess the release year across 10 rounds. -- [hitstar] Backend NX library `spotify` with iTunes Search API integration and random track endpoint (`GET /spotify/random-track`). +- [hitstar] Renamed backend NX library `spotify` to `hitstar`; endpoint changed from `GET /spotify/random-track` to `GET /hitstar/random-track`. - [hitstar] Frontend route `/hitstar` with state machine (MENU → LOADING → GUESSING → REVEAL → RESULTS). - [hitstar] Audio preview plays automatically in GUESSING state via iTunes preview URL. - [hitstar] CSS 3D flip card animation on answer reveal, confetti for correct guesses, shake animation for wrong answers. diff --git a/backend/apps/tilloh-dev/src/main.ts b/backend/apps/tilloh-dev/src/main.ts index 5f56ddf5..5e096c0c 100644 --- a/backend/apps/tilloh-dev/src/main.ts +++ b/backend/apps/tilloh-dev/src/main.ts @@ -1,6 +1,6 @@ import { AdminModule } from '@backend/admin'; import { JokesModule } from '@backend/jokes'; -import { SpotifyModule } from '@backend/spotify'; +import { HitstarModule } from '@backend/hitstar'; import { MemorandumModule } from '@backend/memorandum'; import { OcrModule } from '@backend/ocr'; import { SharedControllerHealthModule } from '@backend/shared-controller-health'; @@ -72,7 +72,7 @@ import { EnvironmentVariables, validate } from './env.validation'; MemorandumModule, JokesModule, OcrModule, - SpotifyModule, + HitstarModule, TodoControllerModule, ], providers: [ diff --git a/backend/libs/spotify/.eslintrc.json b/backend/libs/hitstar/.eslintrc.json similarity index 100% rename from backend/libs/spotify/.eslintrc.json rename to backend/libs/hitstar/.eslintrc.json diff --git a/backend/libs/spotify/jest.config.ts b/backend/libs/hitstar/jest.config.ts similarity index 76% rename from backend/libs/spotify/jest.config.ts rename to backend/libs/hitstar/jest.config.ts index 22344168..4f8f0f24 100644 --- a/backend/libs/spotify/jest.config.ts +++ b/backend/libs/hitstar/jest.config.ts @@ -1,11 +1,11 @@ /* eslint-disable */ export default { - displayName: 'spotify', + displayName: 'hitstar', preset: '../../jest.preset.js', testEnvironment: 'node', transform: { '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], }, moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../coverage/libs/spotify', + coverageDirectory: '../../coverage/libs/hitstar', }; diff --git a/backend/libs/spotify/project.json b/backend/libs/hitstar/project.json similarity index 78% rename from backend/libs/spotify/project.json rename to backend/libs/hitstar/project.json index d0da463f..23235490 100644 --- a/backend/libs/spotify/project.json +++ b/backend/libs/hitstar/project.json @@ -1,7 +1,7 @@ { - "name": "spotify", + "name": "hitstar", "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "libs/spotify/src", + "sourceRoot": "libs/hitstar/src", "projectType": "library", "tags": [], "targets": { @@ -17,7 +17,7 @@ "{workspaceRoot}/coverage/{projectRoot}" ], "options": { - "jestConfig": "libs/spotify/jest.config.ts" + "jestConfig": "libs/hitstar/jest.config.ts" } } } diff --git a/backend/libs/hitstar/src/index.ts b/backend/libs/hitstar/src/index.ts new file mode 100644 index 00000000..52e1eed4 --- /dev/null +++ b/backend/libs/hitstar/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/hitstar.controller'; +export * from './lib/hitstar.module'; diff --git a/backend/libs/spotify/src/lib/spotify.controller.spec.ts b/backend/libs/hitstar/src/lib/hitstar.controller.spec.ts similarity index 69% rename from backend/libs/spotify/src/lib/spotify.controller.spec.ts rename to backend/libs/hitstar/src/lib/hitstar.controller.spec.ts index 0873a941..241ac8b2 100644 --- a/backend/libs/spotify/src/lib/spotify.controller.spec.ts +++ b/backend/libs/hitstar/src/lib/hitstar.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { SpotifyController } from './spotify.controller'; -import { SpotifyService } from './spotify.service'; +import { HitstarController } from './hitstar.controller'; +import { HitstarService } from './hitstar.service'; const mockTrack = { id: 'track-id-1', @@ -13,16 +13,16 @@ const mockTrack = { previewUrl: 'https://p.scdn.co/mp3-preview/abc123', }; -describe('SpotifyController', () => { - let controller: SpotifyController; - let service: SpotifyService; +describe('HitstarController', () => { + let controller: HitstarController; + let service: HitstarService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - controllers: [SpotifyController], + controllers: [HitstarController], providers: [ { - provide: SpotifyService, + provide: HitstarService, useValue: { getRandomTrack: jest.fn().mockResolvedValue(mockTrack), }, @@ -30,8 +30,8 @@ describe('SpotifyController', () => { ], }).compile(); - controller = module.get(SpotifyController); - service = module.get(SpotifyService); + controller = module.get(HitstarController); + service = module.get(HitstarService); }); it('should be defined', () => { diff --git a/backend/libs/spotify/src/lib/spotify.controller.ts b/backend/libs/hitstar/src/lib/hitstar.controller.ts similarity index 67% rename from backend/libs/spotify/src/lib/spotify.controller.ts rename to backend/libs/hitstar/src/lib/hitstar.controller.ts index b19795f3..811aab77 100644 --- a/backend/libs/spotify/src/lib/spotify.controller.ts +++ b/backend/libs/hitstar/src/lib/hitstar.controller.ts @@ -6,12 +6,12 @@ import { ApiOkResponse, ApiTags, } from '@nestjs/swagger'; -import { SpotifyService } from './spotify.service'; +import { HitstarService } from './hitstar.service'; -@ApiTags('spotify') -@Controller('/spotify') -export class SpotifyController { - constructor(private spotifyService: SpotifyService) {} +@ApiTags('hitstar') +@Controller('/hitstar') +export class HitstarController { + constructor(private hitstarService: HitstarService) {} @Public() @ApiOkResponse({ @@ -21,6 +21,6 @@ export class SpotifyController { @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) @Get('/random-track') getRandomTrack() { - return this.spotifyService.getRandomTrack(); + return this.hitstarService.getRandomTrack(); } } diff --git a/backend/libs/hitstar/src/lib/hitstar.module.ts b/backend/libs/hitstar/src/lib/hitstar.module.ts new file mode 100644 index 00000000..2773713e --- /dev/null +++ b/backend/libs/hitstar/src/lib/hitstar.module.ts @@ -0,0 +1,13 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { HitstarController } from './hitstar.controller'; +import { HitstarService } from './hitstar.service'; + +@Module({ + imports: [HttpModule, ConfigModule], + controllers: [HitstarController], + providers: [HitstarService], + exports: [], +}) +export class HitstarModule {} diff --git a/backend/libs/spotify/src/lib/spotify.service.spec.ts b/backend/libs/hitstar/src/lib/hitstar.service.spec.ts similarity index 93% rename from backend/libs/spotify/src/lib/spotify.service.spec.ts rename to backend/libs/hitstar/src/lib/hitstar.service.spec.ts index a56a9ffc..11be09d1 100644 --- a/backend/libs/spotify/src/lib/spotify.service.spec.ts +++ b/backend/libs/hitstar/src/lib/hitstar.service.spec.ts @@ -2,7 +2,7 @@ import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { of } from 'rxjs'; -import { SpotifyService } from './spotify.service'; +import { HitstarService } from './hitstar.service'; const mockTokenResponse = { data: { @@ -32,14 +32,14 @@ const mockSpotifyResponse = { }, }; -describe('SpotifyService', () => { - let service: SpotifyService; +describe('HitstarService', () => { + let service: HitstarService; let httpService: HttpService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - SpotifyService, + HitstarService, { provide: HttpService, useValue: { get: jest.fn(), post: jest.fn() }, @@ -51,7 +51,7 @@ describe('SpotifyService', () => { ], }).compile(); - service = module.get(SpotifyService); + service = module.get(HitstarService); httpService = module.get(HttpService); }); diff --git a/backend/libs/hitstar/src/lib/hitstar.service.ts b/backend/libs/hitstar/src/lib/hitstar.service.ts new file mode 100644 index 00000000..c60812a2 --- /dev/null +++ b/backend/libs/hitstar/src/lib/hitstar.service.ts @@ -0,0 +1,127 @@ +import { SpotifyTrackDto } from '@backend/shared-types'; +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; + +// Available spotify genres +// acoustic, afrobeat, alt-rock, alternative, ambient, anime, black-metal, bluegrass, blues, bossanova, brazil, breakbeat, british, cantopop, chicago-house, children, chill, classical, club, comedy, country, dance, dancehall, death-metal, deep-house, detroit-techno, disco, disney, drum-and-bass, dub, dubstep, edm, electro, electronic, emo, folk, forro, french, funk, garage, german, gospel, goth, grindcore, groove, grunge, guitar, happy, hard-rock, hardcore, hardstyle, heavy-metal, hip-hop, holidays, honky-tonk, house, idm, indian, indie, indie-pop, industrial, iranian, j-dance, j-idol, j-pop, j-rock, jazz, k-pop, kids, latin, latino, malay, mandopop, metal, metal-misc, metalcore, minimal-techno, movies, mpb, new-age, new-release, opera, pagode, party, philippines-opm, piano, pop, pop-film, post-dubstep, power-pop, progressive-house, psych-rock, punk, punk-rock, r-n-b, rainy-day, reggae, reggaeton, road-trip, rock, rock-n-roll, rockabilly, romance, sad, salsa, samba, sertanejo, show-tunes, singer-songwriter, ska, sleep, songwriter, soul, soundtracks, spanish, study, summer, swedish, synth-pop, tango, techno, trance, trip-hop, turkish, work-out, world-music + +@Injectable() +export class HitstarService { + private readonly logger = new Logger(HitstarService.name); + private accessToken: string | null = null; + private tokenExpiresAt = 0; + + constructor( + private httpService: HttpService, + private configService: ConfigService, + ) {} + + private async getAccessToken(): Promise { + if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { + return this.accessToken; + } + + const clientId = this.configService.get('SPOTIFY_CLIENT_ID'); + const clientSecret = this.configService.get( + 'SPOTIFY_CLIENT_SECRET', + ); + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString( + 'base64', + ); + + const response = await firstValueFrom( + this.httpService.post( + 'https://accounts.spotify.com/api/token', + 'grant_type=client_credentials', + { + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ), + ); + + this.accessToken = response.data.access_token as string; + this.tokenExpiresAt = + Date.now() + (response.data.expires_in as number) * 1000; + return this.accessToken; + } + + async getRandomTrack(): Promise { + const maxAttempts = 10; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const offset = Math.floor(Math.random() * 100); + const token = await this.getAccessToken(); + + this.logger.log( + `Attempt ${attempt + 1}: searching Spotify (offset=${offset})`, + ); + + try { + const year = 1955 + Math.floor(Math.random() * (2025 - 1955 + 1)); + const searchUrl = `https://api.spotify.com/v1/search?q=*+year%3A${year}+genre%3Apop&type=track&market=DE&limit=10&offset=${offset}`; + this.logger.log(`Fetching URL: ${searchUrl}`); + const response = await firstValueFrom( + this.httpService.get(searchUrl, { + headers: { Authorization: `Bearer ${token}` }, + }), + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const items: any[] = response.data?.tracks?.items ?? []; + const validTracks = items.filter((t) => t.album?.release_date); + + this.logger.log( + `Attempt ${attempt + 1}: ${items.length} tracks fetched, ${validTracks.length} usable`, + ); + + if (validTracks.length > 0) { + const track = + validTracks[Math.floor(Math.random() * validTracks.length)]; + const releaseYear = parseInt( + (track.album.release_date as string).substring(0, 4), + 10, + ); + const albumCover: string = track.album?.images?.[0]?.url ?? ''; + const artist: string = (track.artists as { name: string }[]) + .map((a) => a.name) + .join(', '); + + this.logger.log( + `Found: "${track.name as string}" by ${artist} (${releaseYear})`, + ); + + return { + id: track.id as string, + name: track.name as string, + artist, + album: track.album.name as string, + albumCover, + releaseYear, + spotifyUrl: (track.external_urls?.spotify as string) ?? '', + previewUrl: track.preview_url as string, + }; + } + + this.logger.warn( + `Attempt ${attempt + 1}: no usable track found. HTTP ${response.status}, response: ${JSON.stringify(response.data)}`, + ); + } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response + ?.status; + const data = (err as { response?: { data?: unknown } })?.response?.data; + this.logger.warn( + `Attempt ${attempt + 1} failed. HTTP ${status ?? 'n/a'}, response: ${JSON.stringify(data ?? err)}`, + ); + } + } + + throw new Error( + `Could not find a random track after ${maxAttempts} attempts.`, + ); + } +} diff --git a/backend/libs/spotify/tsconfig.json b/backend/libs/hitstar/tsconfig.json similarity index 100% rename from backend/libs/spotify/tsconfig.json rename to backend/libs/hitstar/tsconfig.json diff --git a/backend/libs/spotify/tsconfig.lib.json b/backend/libs/hitstar/tsconfig.lib.json similarity index 100% rename from backend/libs/spotify/tsconfig.lib.json rename to backend/libs/hitstar/tsconfig.lib.json diff --git a/backend/libs/spotify/tsconfig.spec.json b/backend/libs/hitstar/tsconfig.spec.json similarity index 100% rename from backend/libs/spotify/tsconfig.spec.json rename to backend/libs/hitstar/tsconfig.spec.json diff --git a/backend/libs/shared/provider/keystore-persistence/src/index.ts b/backend/libs/shared/provider/keystore-persistence/src/index.ts index 2cb6bab7..2ce60d16 100644 --- a/backend/libs/shared/provider/keystore-persistence/src/index.ts +++ b/backend/libs/shared/provider/keystore-persistence/src/index.ts @@ -1,3 +1,5 @@ export * from './lib/keystore-mongodb.service'; export * from './lib/schema/keystore.schema'; export * from './lib/shared-keystore-persistence.module'; +export * from './lib/toggle-seed.config'; +export * from './lib/toggle-seed.service'; diff --git a/backend/libs/shared/provider/keystore-persistence/src/lib/shared-keystore-persistence.module.ts b/backend/libs/shared/provider/keystore-persistence/src/lib/shared-keystore-persistence.module.ts index 87d43671..c6c094aa 100644 --- a/backend/libs/shared/provider/keystore-persistence/src/lib/shared-keystore-persistence.module.ts +++ b/backend/libs/shared/provider/keystore-persistence/src/lib/shared-keystore-persistence.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { KeystoreMongoDbService } from './keystore-mongodb.service'; import { Keystore, KeystoreSchema } from './schema/keystore.schema'; +import { ToggleSeedService } from './toggle-seed.service'; @Module({ imports: [ @@ -10,7 +11,7 @@ import { Keystore, KeystoreSchema } from './schema/keystore.schema'; ]), ], controllers: [], - providers: [KeystoreMongoDbService], + providers: [KeystoreMongoDbService, ToggleSeedService], exports: [KeystoreMongoDbService], }) export class SharedKeystorePersistenceModule {} diff --git a/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.config.ts b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.config.ts new file mode 100644 index 00000000..4a04fd12 --- /dev/null +++ b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.config.ts @@ -0,0 +1,23 @@ +export const TOGGLE_IDENTIFIER = 'tilloh-toggles'; + +export interface ToggleSeedEntry { + key: string; + defaultValue: string; +} + +export const TOGGLE_SEED_CONFIG: ToggleSeedEntry[] = [ + { key: 'TOGGLE_RANDOM_JOKE', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_MEMORANDUM', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_TODO', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_FOOD_SCAN', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_JOKES', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_CATCH_EM_ALL', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_UNO_SORT', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_ABOUT', defaultValue: 'true' }, + { key: 'TOGGLE_ADMIN_DASHBOARD', defaultValue: 'true' }, + { key: 'TOGGLE_ADMIN_ACTIVITIES', defaultValue: 'true' }, + { key: 'TOGGLE_ADMIN_IDENTIFIERS', defaultValue: 'true' }, + { key: 'TOGGLE_ADMIN_JOKES', defaultValue: 'true' }, + { key: 'TOGGLE_ADMIN_LINK_PRESETS', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_HITSTAR', defaultValue: 'true' }, +]; diff --git a/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.spec.ts b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.spec.ts new file mode 100644 index 00000000..19757ced --- /dev/null +++ b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.spec.ts @@ -0,0 +1,109 @@ +import { mockKeystoreDto } from '@backend/util'; +import { Test, TestingModule } from '@nestjs/testing'; +import { KeystoreMongoDbService } from './keystore-mongodb.service'; +import { TOGGLE_IDENTIFIER, TOGGLE_SEED_CONFIG } from './toggle-seed.config'; +import { ToggleSeedService } from './toggle-seed.service'; + +describe('ToggleSeedService', () => { + let service: ToggleSeedService; + let keystoreService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ToggleSeedService, + { + provide: KeystoreMongoDbService, + useValue: { + findAll: jest.fn(), + create: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ToggleSeedService); + keystoreService = module.get(KeystoreMongoDbService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined.', () => { + expect(service).toBeDefined(); + }); + + describe('onApplicationBootstrap', () => { + it('should create all toggles when DB is empty', async () => { + // arrange + keystoreService.findAll.mockResolvedValue([]); + keystoreService.create.mockResolvedValue( + mockKeystoreDto({ identifier: TOGGLE_IDENTIFIER }), + ); + + // act + await service.onApplicationBootstrap(); + + // assert + expect(keystoreService.findAll).toHaveBeenCalledWith({ + identifier: TOGGLE_IDENTIFIER, + }); + expect(keystoreService.create).toHaveBeenCalledTimes( + TOGGLE_SEED_CONFIG.length, + ); + }); + + it('should not create any toggles when all already exist', async () => { + // arrange + const existingToggles = TOGGLE_SEED_CONFIG.map((entry) => + mockKeystoreDto({ identifier: TOGGLE_IDENTIFIER, key: entry.key }), + ); + keystoreService.findAll.mockResolvedValue(existingToggles); + + // act + await service.onApplicationBootstrap(); + + // assert + expect(keystoreService.create).not.toHaveBeenCalled(); + }); + + it('should only create missing toggles when DB is partially populated', async () => { + // arrange + const existingKeys = TOGGLE_SEED_CONFIG.slice(0, 5); + const existingToggles = existingKeys.map((entry) => + mockKeystoreDto({ identifier: TOGGLE_IDENTIFIER, key: entry.key }), + ); + keystoreService.findAll.mockResolvedValue(existingToggles); + keystoreService.create.mockResolvedValue( + mockKeystoreDto({ identifier: TOGGLE_IDENTIFIER }), + ); + + // act + await service.onApplicationBootstrap(); + + // assert + expect(keystoreService.create).toHaveBeenCalledTimes( + TOGGLE_SEED_CONFIG.length - existingKeys.length, + ); + }); + + it('should continue seeding remaining toggles when one create fails', async () => { + // arrange + keystoreService.findAll.mockResolvedValue([]); + keystoreService.create + .mockRejectedValueOnce(new Error('create failed')) + .mockResolvedValue( + mockKeystoreDto({ identifier: TOGGLE_IDENTIFIER }), + ); + + // act + await service.onApplicationBootstrap(); + + // assert + expect(keystoreService.create).toHaveBeenCalledTimes( + TOGGLE_SEED_CONFIG.length, + ); + }); + }); +}); diff --git a/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.ts b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.ts new file mode 100644 index 00000000..209c7179 --- /dev/null +++ b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { KeystoreMongoDbService } from './keystore-mongodb.service'; +import { + TOGGLE_IDENTIFIER, + TOGGLE_SEED_CONFIG, +} from './toggle-seed.config'; + +@Injectable() +export class ToggleSeedService implements OnApplicationBootstrap { + private readonly logger = new Logger(ToggleSeedService.name); + + constructor(private readonly keystoreService: KeystoreMongoDbService) {} + + async onApplicationBootstrap(): Promise { + this.logger.debug('Starting toggle seed check...'); + + const existingToggles = await this.keystoreService.findAll({ + identifier: TOGGLE_IDENTIFIER, + }); + + const existingKeys = new Set(existingToggles.map((t) => t.key)); + + let created = 0; + let alreadyExisted = 0; + + for (const entry of TOGGLE_SEED_CONFIG) { + if (existingKeys.has(entry.key)) { + alreadyExisted++; + continue; + } + + try { + await this.keystoreService.create( + TOGGLE_IDENTIFIER, + entry.key, + entry.defaultValue, + ); + this.logger.debug(`Toggle created: ${entry.key}=${entry.defaultValue}`); + created++; + } catch (error) { + this.logger.error( + `Failed to create toggle ${entry.key}: ${(error as Error).message}`, + ); + } + } + + this.logger.log( + `Toggle seed complete: ${created} created, ${alreadyExisted} already existed.`, + ); + } +} diff --git a/backend/libs/spotify/src/index.ts b/backend/libs/spotify/src/index.ts deleted file mode 100644 index 096a05e8..00000000 --- a/backend/libs/spotify/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './lib/spotify.controller'; -export * from './lib/spotify.module'; diff --git a/backend/libs/spotify/src/lib/spotify.module.ts b/backend/libs/spotify/src/lib/spotify.module.ts deleted file mode 100644 index 992daae1..00000000 --- a/backend/libs/spotify/src/lib/spotify.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { HttpModule } from '@nestjs/axios'; -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { SpotifyController } from './spotify.controller'; -import { SpotifyService } from './spotify.service'; - -@Module({ - imports: [HttpModule, ConfigModule], - controllers: [SpotifyController], - providers: [SpotifyService], - exports: [], -}) -export class SpotifyModule {} diff --git a/backend/libs/spotify/src/lib/spotify.service.ts b/backend/libs/spotify/src/lib/spotify.service.ts deleted file mode 100644 index d38faa03..00000000 --- a/backend/libs/spotify/src/lib/spotify.service.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { SpotifyTrackDto } from '@backend/shared-types'; -import { HttpService } from '@nestjs/axios'; -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { firstValueFrom } from 'rxjs'; - -@Injectable() -export class SpotifyService { - private readonly logger = new Logger(SpotifyService.name); - private accessToken: string | null = null; - private tokenExpiresAt = 0; - - constructor( - private httpService: HttpService, - private configService: ConfigService, - ) {} - - private async getAccessToken(): Promise { - if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { - return this.accessToken; - } - - const clientId = this.configService.get('SPOTIFY_CLIENT_ID'); - const clientSecret = this.configService.get('SPOTIFY_CLIENT_SECRET'); - const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); - - const response = await firstValueFrom( - this.httpService.post( - 'https://accounts.spotify.com/api/token', - 'grant_type=client_credentials', - { - headers: { - Authorization: `Basic ${credentials}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }, - ), - ); - - this.accessToken = response.data.access_token as string; - this.tokenExpiresAt = Date.now() + (response.data.expires_in as number) * 1000; - return this.accessToken; - } - - async getRandomTrack(): Promise { - const maxAttempts = 10; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const offset = Math.floor(Math.random() * 50); - const token = await this.getAccessToken(); - - this.logger.log(`Attempt ${attempt + 1}: searching Spotify (offset=${offset})`); - - try { - const searchUrl = `https://api.spotify.com/v1/search?q=*+year%3A1950-2025&type=track&market=DE&limit=1&offset=${offset}`; - const response = await firstValueFrom( - this.httpService.get(searchUrl, { - headers: { Authorization: `Bearer ${token}` }, - }), - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const items: any[] = response.data?.tracks?.items ?? []; - const validTracks = items.filter((t) => t.preview_url && t.album?.release_date); - - if (validTracks.length > 0) { - const track = validTracks[0]; - const releaseYear = parseInt( - (track.album.release_date as string).substring(0, 4), - 10, - ); - const albumCover: string = track.album?.images?.[0]?.url ?? ''; - const artist: string = (track.artists as { name: string }[]) - .map((a) => a.name) - .join(', '); - - this.logger.log(`Found: "${track.name as string}" by ${artist} (${releaseYear})`); - - return { - id: track.id as string, - name: track.name as string, - artist, - album: track.album.name as string, - albumCover, - releaseYear, - spotifyUrl: (track.external_urls?.spotify as string) ?? '', - previewUrl: track.preview_url as string, - }; - } - - this.logger.warn(`Attempt ${attempt + 1}: no usable track found, retrying.`); - } catch (err) { - this.logger.warn(`Attempt ${attempt + 1} failed: ${err}`); - } - } - - throw new Error(`Could not find a random track after ${maxAttempts} attempts.`); - } -} diff --git a/backend/tsconfig.base.json b/backend/tsconfig.base.json index 38ea8d32..6d8d5b50 100644 --- a/backend/tsconfig.base.json +++ b/backend/tsconfig.base.json @@ -44,7 +44,7 @@ "@backend/shared-identifiers": [ "libs/shared/provider/identifiers/src/index.ts" ], - "@backend/spotify": ["libs/spotify/src/index.ts"] + "@backend/hitstar": ["libs/hitstar/src/index.ts"] } }, "exclude": ["node_modules", "tmp"] diff --git a/frontend/src/lib/api/spotify.api.ts b/frontend/src/lib/api/spotify.api.ts index ef020d28..ac802a14 100644 --- a/frontend/src/lib/api/spotify.api.ts +++ b/frontend/src/lib/api/spotify.api.ts @@ -2,7 +2,7 @@ import type { SpotifyTrackDto } from '$lib/types/spotify.dto'; import { getApiURL } from '$lib/util/environment'; export const getRandomTrack = async (): Promise => { - const res = await fetch(`${getApiURL()}/spotify/random-track`); + const res = await fetch(`${getApiURL()}/hitstar/random-track`); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err?.message ?? `HTTP ${res.status}`); diff --git a/frontend/src/lib/components/hitstar/Hitstar.svelte b/frontend/src/lib/components/hitstar/Hitstar.svelte index 23d7f063..8a8db668 100644 --- a/frontend/src/lib/components/hitstar/Hitstar.svelte +++ b/frontend/src/lib/components/hitstar/Hitstar.svelte @@ -1,408 +1,42 @@ {#if $initialized} -
- - {#if gameState === 'MENU'} - - - - {:else if gameState === 'LOADING'} -
-

{$t('page.hitstar.loading.headline')}

- -
- - - {:else if gameState === 'GUESSING'} -
- -
-
-

- {$t('page.hitstar.guessing.cardHint')} -

-
-
- {#each roundIndices as _, i} - {@const roundNum = i + 1} - {@const result = roundResults.find((r) => r.round === roundNum)} -
- {/each} -
-
- - -
- - {#if currentTrack?.previewUrl} - - {/if} -
- - -
-
- -
-
-
- - - {:else if gameState === 'REVEAL'} -
- -
-
-

- {$t('page.hitstar.guessing.headline', { - round: String(currentRound), - })} -

-
-
- {#each roundIndices as _, i} - {@const roundNum = i + 1} - {@const result = roundResults.find((r) => r.round === roundNum)} -
- {/each} -
-
- - -
-
- -
- - {#if currentTrack?.previewUrl} - - {/if} -
- - -
- -
-
- - - {:else if gameState === 'RESULTS'} -
- -
-

{$t('page.hitstar.results.headline')}

- {#if isNewBestScore && score > 0} - - {/if} -
- - -
-
- {#each roundResults as result} - -
-
-
- {$t('page.hitstar.results.round', { - round: String(result.round), - })} - {result.track.name} – {result.track.artist} -
-
-
- {$t('page.hitstar.results.yourGuess', { - year: String(result.guessedYear), - })} - {$t('page.hitstar.results.correctYear', { - year: String(result.track.releaseYear), - })} -
-
- {/each} -
-
- - -
- -
-
+
+ {#if gameActive} +

+ {selectedTab === 0 ? $t('page.hitstar.tabs.classic') : $t('page.hitstar.tabs.range')} +

{/if} + + + + + + + + + + + +
{/if} @@ -411,324 +45,80 @@ display: flex; flex-direction: column; align-items: center; - padding: 1.5rem 1rem; - gap: 1.5rem; flex: 1; box-sizing: border-box; - } - - .menu-screen, - .loading-screen { - display: flex; - flex-direction: column; - align-items: center; - gap: 1.5rem; - width: 100%; - } - - .results-screen { - display: flex; - flex-direction: column; - align-items: center; width: 100%; - flex: 1; } - .results-top-section { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - width: 100%; - padding-bottom: 0.75rem; + .mode-label { + align-self: flex-start; + font-size: 0.75rem; + color: var(--cds-text-helper, #6f6f6f); + margin: 0 0 0.25rem; } - .results-middle-section { - flex: 1; - width: 100%; - overflow-y: auto; - padding-right: 2px; + /* Tabs always visible as horizontal bar, no dropdown */ + .hitstar-container :global(.bx--tabs-trigger) { + display: none; } - .guessing-screen, - .reveal-screen { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - flex: 1; + .hitstar-container :global(.bx--tabs__nav--hidden) { + display: flex !important; + flex-direction: row !important; + max-height: unset !important; + overflow: visible !important; + position: static !important; } - /* ── Section layout within game screens ───────────────── */ - .game-top-section { - display: flex; - flex-direction: column; - gap: 0.75rem; + /* Full-width tab container, nav centered and only as wide as its items */ + .hitstar-container :global(.bx--tabs) { width: 100%; - } - - .game-middle-section { - flex: 1; display: flex; - flex-direction: column; - align-items: center; justify-content: center; - gap: var(--default_padding); - width: 100%; } - .bottom-action { - padding-top: 0.75rem; - padding-bottom: 0.75rem; - width: 100%; - display: flex; - justify-content: center; + .hitstar-container :global(.bx--tabs__nav) { + width: fit-content; } - /* ── Top bar: headline + exit button ─────────────────── */ - .game-top-bar { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - gap: 0.75rem; + .hitstar-container :global(.bx--tabs__nav-item) { + display: flex !important; + flex: unset; } - .game-question { - font-size: 1.1rem; + /* Active tab indicator (Carbon hides selected item on mobile by default) */ + .hitstar-container :global(.bx--tabs__nav-item--selected .bx--tabs__nav-link) { + border-bottom: 2px solid var(--cds-interactive-04, #0f62fe) !important; + color: var(--cds-text-01, #f4f4f4) !important; font-weight: 600; - margin: 0; - flex: 1; - line-height: 1.3; - } - - /* ── Round progress tracker ───────────────────────────── */ - .round-tracker { - display: flex; - gap: 5px; - width: 100%; - } - - .round-dot { - flex: 1; - height: 10px; - border-radius: 3px; - background: var(--cds-layer-02, #393939); - border: 1px solid var(--cds-border-subtle-01, #525252); - transition: - background 0.3s, - border-color 0.3s; } - .round-dot.correct { - background: var(--cds-support-success, #24a148); - border-color: var(--cds-support-success, #24a148); + /* Remove focus ring on tab links — selection shown by bottom border only */ + .hitstar-container :global(.bx--tabs__nav-link:focus), + .hitstar-container :global(.bx--tabs__nav-link:active) { + outline: none; + box-shadow: none; } - .round-dot.wrong { - background: var(--cds-support-error, #da1e28); - border-color: var(--cds-support-error, #da1e28); - } - - /* ── Year input + submit inline ───────────────────────── */ - .input-submit-row { - display: flex; - align-items: flex-end; - width: 100%; - max-width: 360px; - padding-top: 0.75rem; - padding-bottom: 0.75rem; - } - - .input-wrapper { - flex: 1; - min-width: 0; - } - - /* Force submit button to match NumberInput height (40px) and center icon */ - .input-submit-row :global(button) { - height: 2.5rem; - width: 2.5rem; - min-height: unset; - margin-left: 0; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - } - - .input-submit-row :global(button svg) { - margin: 0; - position: static; - } - - /* ── Menu styles ──────────────────────────────────────── */ - .headline { - font-size: 2rem; - font-weight: bold; - text-align: center; - margin: 0; - } - - .description { - text-align: center; - color: var(--cds-text-secondary); - margin: 0; - max-width: 480px; - } - - .best-score-label { - font-size: 0.85rem; - color: var(--cds-text-secondary); - margin: 0 0 0.3rem; - } - - .best-score-value, - .best-score-empty { - font-size: 1.1rem; - font-weight: bold; - margin: 0; - } - - .best-score-empty { - color: var(--cds-text-secondary); - } - - /* ── Audio ────────────────────────────────────────────── */ - .audio-player { - width: 100%; - max-width: 400px; - border-radius: 8px; - } - - /* ── Results ──────────────────────────────────────────── */ - .results-table { - display: flex; - flex-direction: column; - gap: 0.5rem; - width: 100%; - } - - :global(.result-tile.correct) { - border-left: 4px solid #24a148 !important; - } - - :global(.result-tile.wrong) { - border-left: 4px solid #da1e28 !important; - } - - .result-row { - display: flex; - align-items: center; - gap: 0.75rem; - } - - .result-indicator { - width: 14px; - height: 14px; - border-radius: 3px; - flex-shrink: 0; - } - - .indicator-correct { - background: #24a148; - } - - .indicator-wrong { - background: #da1e28; - } - - .result-content { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; - min-width: 0; - } - - .result-round { - font-weight: bold; - white-space: nowrap; - flex-shrink: 0; - } - - .result-track { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.9rem; - color: var(--cds-text-secondary); - } - - .result-years { - display: flex; - gap: 1rem; - font-size: 0.8rem; - color: var(--cds-text-secondary); - margin-top: 0.25rem; - padding-left: 1.75rem; - } - - /* ── Shake animation ──────────────────────────────────── */ - @keyframes shake { - 0%, - 100% { - transform: translateX(0); - } - 10%, - 30%, - 50%, - 70%, - 90% { - transform: translateX(-5px); - } - 20%, - 40%, - 60%, - 80% { - transform: translateX(5px); + /* Prevent Carbon from resizing tab links on focus/active on mobile + (Carbon sets width:100%, padding-left:16px, margin:0 which causes visual shift) */ + @media (max-width: 41.9375rem) { + .hitstar-container :global(.bx--tabs__nav-link:focus), + .hitstar-container :global(.bx--tabs__nav-link:active) { + width: calc(100% - 32px) !important; + padding-left: 0 !important; + margin: 0 var(--cds-spacing-05, 1rem) !important; } } - .shake { - animation: shake 0.5s ease-in-out; + /* Margin between tab bar and mode content */ + .hitstar-container :global(.bx--tab-content) { + padding: 0; + margin-top: calc(2 * var(--default_padding)); } - /* ── Mobile adjustments ───────────────────────────────── */ - @media (max-width: 480px) { - .hitstar-container { - padding: 0.5rem 0.75rem; - gap: 0; - } - - .game-top-section { - gap: 0.5rem; - padding-bottom: 0.25rem; - } - - .game-question { - font-size: 0.9rem; - } - - .input-submit-row { - max-width: 100%; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - } - - .bottom-action { - padding-top: 0.5rem; - padding-bottom: 0.5rem; - } - - .round-dot { - height: 6px; - } - - .audio-player { - max-height: 40px; - } + /* Hide tabs during active game */ + .game-active :global(.bx--tabs) { + display: none; } diff --git a/frontend/src/lib/components/hitstar/HitstarCard.svelte b/frontend/src/lib/components/hitstar/HitstarCard.svelte index 6b1dc4b4..716dac90 100644 --- a/frontend/src/lib/components/hitstar/HitstarCard.svelte +++ b/frontend/src/lib/components/hitstar/HitstarCard.svelte @@ -165,6 +165,12 @@ font-size: 4rem; } + .album-cover { + width: min(90px, 28vw); + height: min(90px, 28vw); + margin-bottom: 0.25rem; + } + .track-name { font-size: 0.85rem; } diff --git a/frontend/src/lib/components/hitstar/HitstarClassic.svelte b/frontend/src/lib/components/hitstar/HitstarClassic.svelte new file mode 100644 index 00000000..25d38a6f --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarClassic.svelte @@ -0,0 +1,494 @@ + + + +{#if gameState === 'MENU'} + + + +{:else if gameState === 'LOADING'} + + + +{:else if gameState === 'GUESSING'} +
+
+ + +
+ +
+ + +
+ +
+
+ +
+
+
+ + +{:else if gameState === 'REVEAL'} +
+
+ + +
+ +
+
+ +
+
+ +
+ +
+
+ + +{:else if gameState === 'RESULTS'} + + {#snippet guessDisplay(result)} + {$t('page.hitstar.results.yourGuess', { year: String(result.guessedYear) })} + {/snippet} + +{/if} + + diff --git a/frontend/src/lib/components/hitstar/HitstarGameTopBar.svelte b/frontend/src/lib/components/hitstar/HitstarGameTopBar.svelte new file mode 100644 index 00000000..266c587a --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarGameTopBar.svelte @@ -0,0 +1,48 @@ + + +
+

{headline}

+
+ + diff --git a/frontend/src/lib/components/hitstar/HitstarLoadingScreen.svelte b/frontend/src/lib/components/hitstar/HitstarLoadingScreen.svelte new file mode 100644 index 00000000..9035e289 --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarLoadingScreen.svelte @@ -0,0 +1,22 @@ + + +
+

{headline}

+ +
+ + diff --git a/frontend/src/lib/components/hitstar/HitstarRange.svelte b/frontend/src/lib/components/hitstar/HitstarRange.svelte new file mode 100644 index 00000000..60013e05 --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarRange.svelte @@ -0,0 +1,588 @@ + + + +{#if gameState === 'MENU'} + + + +{:else if gameState === 'LOADING'} + + + +{:else if gameState === 'GUESSING'} +
+
+ + +
+ +
+ + +
+ +
+
+
+ +
+
+ +
+
+ {#if validationError} +

{validationError}

+ {:else if potentialPoints > 0} +

+ {$t('page.hitstar.range.guessing.potentialPoints', { + points: String(potentialPoints), + })} +

+ {/if} +
+
+ + +{:else if gameState === 'REVEAL'} +
+
+ + +
+ +
+
+ +
+
+ +
+ +
+
+ + +{:else if gameState === 'RESULTS'} + + {#snippet guessDisplay(result)} + + {$t('page.hitstar.range.results.yourGuess', { + fromYear: String(result.rangeFrom ?? result.guessedYear), + toYear: String(result.rangeTo ?? result.guessedYear), + })} + + {#if result.pointsEarned && result.pointsEarned > 0} + + {$t('page.hitstar.range.results.points', { points: String(result.pointsEarned) })} + + {/if} + {/snippet} + +{/if} + + diff --git a/frontend/src/lib/components/hitstar/HitstarResultsScreen.svelte b/frontend/src/lib/components/hitstar/HitstarResultsScreen.svelte new file mode 100644 index 00000000..040d502d --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarResultsScreen.svelte @@ -0,0 +1,186 @@ + + +
+
+

{$t('page.hitstar.results.headline')}

+

{score} / {maxScore}

+ {#if isNewBestScore && score > 0} + + {/if} +
+ +
+
+ {#each roundResults as result} + +
+
+
+ {$t('page.hitstar.results.round', { + round: String(result.round), + })} + {result.track.name} – {result.track.artist} +
+
+
+ {@render guessDisplay(result)} + {$t('page.hitstar.results.correctYear', { + year: String(result.track.releaseYear), + })} +
+
+ {/each} +
+
+ +
+ +
+
+ + diff --git a/frontend/src/lib/components/hitstar/HitstarRoundTracker.svelte b/frontend/src/lib/components/hitstar/HitstarRoundTracker.svelte new file mode 100644 index 00000000..8acfaf92 --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarRoundTracker.svelte @@ -0,0 +1,55 @@ + + +
+ {#each { length: totalRounds } as _, i} + {@const roundNum = i + 1} + {@const result = roundResults.find((r) => r.round === roundNum)} +
+ {/each} +
+ + diff --git a/frontend/src/lib/components/hitstar/HitstarSpotifyEmbed.svelte b/frontend/src/lib/components/hitstar/HitstarSpotifyEmbed.svelte new file mode 100644 index 00000000..27f05290 --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarSpotifyEmbed.svelte @@ -0,0 +1,213 @@ + + +{#if trackId} + {#if masked} + +
+
+
+
+ + +
+ {:else} +
+ +
+ {/if} +{/if} + + diff --git a/frontend/src/lib/components/memorandum/Startup.svelte b/frontend/src/lib/components/memorandum/Startup.svelte index dfa1ac9e..821f1e29 100644 --- a/frontend/src/lib/components/memorandum/Startup.svelte +++ b/frontend/src/lib/components/memorandum/Startup.svelte @@ -88,12 +88,6 @@ } } - h3 { - margin: 0; - font-size: 1.1rem; - font-weight: 600; - } - p { margin: 0; font-size: 0.875rem; diff --git a/frontend/src/lib/components/shared/GlobalMenu.svelte b/frontend/src/lib/components/shared/GlobalMenu.svelte index ac94e9e2..e8db6567 100644 --- a/frontend/src/lib/components/shared/GlobalMenu.svelte +++ b/frontend/src/lib/components/shared/GlobalMenu.svelte @@ -1,5 +1,5 @@