From bcfc02791ff6d0caed4da8cc1576cbf789a6a83a Mon Sep 17 00:00:00 2001 From: Atharva Mathapati Date: Fri, 17 Jul 2026 00:43:20 +0100 Subject: [PATCH] feat(likes): add like/My Shows feature (OpenAPI-first) --- api/openapi.yaml | 70 ++++++++ .../controller/SeriesLikeController.java | 46 +++++ .../tso/userprogress/entity/SeriesLike.java | 42 +++++ .../repository/SeriesLikeRepository.java | 32 ++++ .../service/SeriesLikeService.java | 38 ++++ .../db/migration/V4__create_likes_table.sql | 14 ++ .../SeriesLikeControllerIntegrationTest.java | 168 ++++++++++++++++++ web-client/src/App.tsx | 9 + web-client/src/__tests__/App.test.tsx | 5 + web-client/src/api/likes.ts | 22 +++ web-client/src/components/AppHeader.tsx | 14 +- web-client/src/components/SeriesCard.tsx | 59 ++++-- web-client/src/components/SeriesGrid.tsx | 29 +++ .../src/hooks/__tests__/useLikes.test.ts | 93 ++++++++++ web-client/src/hooks/useLikes.ts | 61 +++++++ web-client/src/hooks/useSeriesWithProgress.ts | 37 ++++ web-client/src/pages/LibraryPage.tsx | 46 ++--- web-client/src/pages/MyShowsPage.tsx | 56 ++++++ web-client/src/pages/SeriesDetailPage.tsx | 34 +++- .../src/pages/__tests__/LibraryPage.test.tsx | 5 + .../src/pages/__tests__/MyShowsPage.test.tsx | 95 ++++++++++ .../pages/__tests__/SeriesDetailPage.test.tsx | 5 + 22 files changed, 916 insertions(+), 64 deletions(-) create mode 100644 services/user-progress/src/main/java/com/tso/userprogress/controller/SeriesLikeController.java create mode 100644 services/user-progress/src/main/java/com/tso/userprogress/entity/SeriesLike.java create mode 100644 services/user-progress/src/main/java/com/tso/userprogress/repository/SeriesLikeRepository.java create mode 100644 services/user-progress/src/main/java/com/tso/userprogress/service/SeriesLikeService.java create mode 100644 services/user-progress/src/main/resources/db/migration/V4__create_likes_table.sql create mode 100644 services/user-progress/src/test/java/com/tso/userprogress/controller/SeriesLikeControllerIntegrationTest.java create mode 100644 web-client/src/api/likes.ts create mode 100644 web-client/src/components/SeriesGrid.tsx create mode 100644 web-client/src/hooks/__tests__/useLikes.test.ts create mode 100644 web-client/src/hooks/useLikes.ts create mode 100644 web-client/src/hooks/useSeriesWithProgress.ts create mode 100644 web-client/src/pages/MyShowsPage.tsx create mode 100644 web-client/src/pages/__tests__/MyShowsPage.test.tsx diff --git a/api/openapi.yaml b/api/openapi.yaml index 18589dd..c52ab16 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -151,6 +151,76 @@ paths: schema: $ref: '#/components/schemas/Error' + /user-progress/likes: + get: + tags: [likes] + operationId: listLikes + summary: List the series the current user has liked ("My Shows"), newest first + security: + - bearerAuth: [] + responses: + '200': + description: Series IDs the current user has liked, most recently liked first + content: + application/json: + schema: + type: array + items: + type: string + format: uuid + '401': + description: Missing or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /user-progress/likes/{seriesId}: + put: + tags: [likes] + operationId: likeSeries + summary: Like a series (idempotent — liking an already-liked series is a no-op) + security: + - bearerAuth: [] + parameters: + - name: seriesId + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: The series is liked + '401': + description: Missing or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: [likes] + operationId: unlikeSeries + summary: Remove a like (idempotent — unliking a series that isn't liked is a no-op) + security: + - bearerAuth: [] + parameters: + - name: seriesId + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: The series is not liked + '401': + description: Missing or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /catalog/health: get: tags: [catalog] diff --git a/services/user-progress/src/main/java/com/tso/userprogress/controller/SeriesLikeController.java b/services/user-progress/src/main/java/com/tso/userprogress/controller/SeriesLikeController.java new file mode 100644 index 0000000..2f5e371 --- /dev/null +++ b/services/user-progress/src/main/java/com/tso/userprogress/controller/SeriesLikeController.java @@ -0,0 +1,46 @@ +package com.tso.userprogress.controller; + +import com.tso.userprogress.api.LikesApi; +import com.tso.userprogress.service.SeriesLikeService; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.RestController; + +/** + * "My Shows" — the set of series a user has liked. Implements the generated {@link LikesApi} so the + * contract stays single-sourced in api/openapi.yaml. + */ +@RestController +public class SeriesLikeController implements LikesApi { + + private final SeriesLikeService likeService; + + public SeriesLikeController(SeriesLikeService likeService) { + this.likeService = likeService; + } + + @Override + public ResponseEntity> listLikes() { + return ResponseEntity.ok(likeService.likedSeriesIds(currentUserId())); + } + + @Override + public ResponseEntity likeSeries(UUID seriesId) { + likeService.like(currentUserId(), seriesId); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity unlikeSeries(UUID seriesId) { + likeService.unlike(currentUserId(), seriesId); + return ResponseEntity.noContent().build(); + } + + private UUID currentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return UUID.fromString((String) authentication.getPrincipal()); + } +} diff --git a/services/user-progress/src/main/java/com/tso/userprogress/entity/SeriesLike.java b/services/user-progress/src/main/java/com/tso/userprogress/entity/SeriesLike.java new file mode 100644 index 0000000..332c2fc --- /dev/null +++ b/services/user-progress/src/main/java/com/tso/userprogress/entity/SeriesLike.java @@ -0,0 +1,42 @@ +package com.tso.userprogress.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.hibernate.annotations.CreationTimestamp; + +/** A user "liking" a series — surfaced as "My Shows" on the client. */ +@Entity +@Table(name = "likes") +public class SeriesLike { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "series_id", nullable = false) + private UUID seriesId; + + @CreationTimestamp + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + protected SeriesLike() {} + + public SeriesLike(UUID userId, UUID seriesId) { + this.userId = userId; + this.seriesId = seriesId; + } + + public UUID getSeriesId() { + return seriesId; + } +} diff --git a/services/user-progress/src/main/java/com/tso/userprogress/repository/SeriesLikeRepository.java b/services/user-progress/src/main/java/com/tso/userprogress/repository/SeriesLikeRepository.java new file mode 100644 index 0000000..0a1b4c5 --- /dev/null +++ b/services/user-progress/src/main/java/com/tso/userprogress/repository/SeriesLikeRepository.java @@ -0,0 +1,32 @@ +package com.tso.userprogress.repository; + +import com.tso.userprogress.entity.SeriesLike; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface SeriesLikeRepository extends JpaRepository { + + /** Most-recently-liked first, so "My Shows" lists newest at the top. */ + List findByUserIdOrderByCreatedAtDesc(UUID userId); + + /** + * Atomic idempotent insert: relies on the UNIQUE(user_id, series_id) constraint so concurrent + * likes of the same series can't race into a constraint violation. {@code id} and {@code + * created_at} come from their DB defaults (see V4 migration). + */ + @Modifying + @Query( + value = + "INSERT INTO likes (user_id, series_id) VALUES (:userId, :seriesId)" + + " ON CONFLICT (user_id, series_id) DO NOTHING", + nativeQuery = true) + void insertIgnore(@Param("userId") UUID userId, @Param("seriesId") UUID seriesId); + + void deleteByUserIdAndSeriesId(UUID userId, UUID seriesId); +} diff --git a/services/user-progress/src/main/java/com/tso/userprogress/service/SeriesLikeService.java b/services/user-progress/src/main/java/com/tso/userprogress/service/SeriesLikeService.java new file mode 100644 index 0000000..0e43b4f --- /dev/null +++ b/services/user-progress/src/main/java/com/tso/userprogress/service/SeriesLikeService.java @@ -0,0 +1,38 @@ +package com.tso.userprogress.service; + +import com.tso.userprogress.entity.SeriesLike; +import com.tso.userprogress.repository.SeriesLikeRepository; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SeriesLikeService { + + private final SeriesLikeRepository repository; + + public SeriesLikeService(SeriesLikeRepository repository) { + this.repository = repository; + } + + /** The caller's liked series ids, most recently liked first. */ + @Transactional(readOnly = true) + public List likedSeriesIds(UUID userId) { + return repository.findByUserIdOrderByCreatedAtDesc(userId).stream() + .map(SeriesLike::getSeriesId) + .toList(); + } + + /** Idempotent: liking an already-liked series is a no-op, safe under concurrency. */ + @Transactional + public void like(UUID userId, UUID seriesId) { + repository.insertIgnore(userId, seriesId); + } + + /** Idempotent: unliking a series that isn't liked is a no-op. */ + @Transactional + public void unlike(UUID userId, UUID seriesId) { + repository.deleteByUserIdAndSeriesId(userId, seriesId); + } +} diff --git a/services/user-progress/src/main/resources/db/migration/V4__create_likes_table.sql b/services/user-progress/src/main/resources/db/migration/V4__create_likes_table.sql new file mode 100644 index 0000000..3c5efc3 --- /dev/null +++ b/services/user-progress/src/main/resources/db/migration/V4__create_likes_table.sql @@ -0,0 +1,14 @@ +-- Flyway migration: per-user "liked" series ("My Shows"). +-- One row per (user, series). Like watch progress, series_id references the +-- catalog's series by value only (the catalog lives in a separate service/DB), +-- so there is no cross-service foreign key. + +CREATE TABLE likes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + series_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, series_id) +); + +CREATE INDEX idx_likes_user ON likes(user_id); diff --git a/services/user-progress/src/test/java/com/tso/userprogress/controller/SeriesLikeControllerIntegrationTest.java b/services/user-progress/src/test/java/com/tso/userprogress/controller/SeriesLikeControllerIntegrationTest.java new file mode 100644 index 0000000..2877c6e --- /dev/null +++ b/services/user-progress/src/test/java/com/tso/userprogress/controller/SeriesLikeControllerIntegrationTest.java @@ -0,0 +1,168 @@ +package com.tso.userprogress.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tso.userprogress.model.SignupRequest; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Sql(scripts = "/cleanup.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +class SeriesLikeControllerIntegrationTest { + + @Container + static PostgreSQLContainer postgres = + new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("user_progress") + .withUsername("postgres") + .withPassword("postgres"); + + @DynamicPropertySource + static void configureDatasource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + /** Signs up a user and returns their bearer access token. */ + private String signupAndGetToken(String email) throws Exception { + String response = + mockMvc + .perform( + post("/user-progress/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString(new SignupRequest(email, "password123")))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + return objectMapper.readTree(response).get("accessToken").asText(); + } + + private void like(String token, UUID series) throws Exception { + mockMvc + .perform(put("/user-progress/likes/" + series).header("Authorization", "Bearer " + token)) + .andExpect(status().isNoContent()); + } + + @Test + void likeThenListReturnsIt() throws Exception { + String token = signupAndGetToken("like1@example.com"); + UUID series = UUID.randomUUID(); + + like(token, series); + + mockMvc + .perform(get("/user-progress/likes").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0]").value(series.toString())); + } + + @Test + void likeIsIdempotent() throws Exception { + String token = signupAndGetToken("like2@example.com"); + UUID series = UUID.randomUUID(); + + like(token, series); + like(token, series); // liking again must not error or create a second row + + mockMvc + .perform(get("/user-progress/likes").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + void unlikeRemovesTheLikeAndIsIdempotent() throws Exception { + String token = signupAndGetToken("like3@example.com"); + UUID series = UUID.randomUUID(); + + like(token, series); + + mockMvc + .perform( + delete("/user-progress/likes/" + series).header("Authorization", "Bearer " + token)) + .andExpect(status().isNoContent()); + // Unliking again is a no-op, not an error. + mockMvc + .perform( + delete("/user-progress/likes/" + series).header("Authorization", "Bearer " + token)) + .andExpect(status().isNoContent()); + + mockMvc + .perform(get("/user-progress/likes").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + void listReturnsNewestLikedFirst() throws Exception { + String token = signupAndGetToken("like4@example.com"); + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + + like(token, first); + Thread.sleep(10); // ensure a distinct created_at ordering + like(token, second); + + mockMvc + .perform(get("/user-progress/likes").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0]").value(second.toString())) + .andExpect(jsonPath("$[1]").value(first.toString())); + } + + @Test + void listReturnsOnlyCallersLikes() throws Exception { + String tokenA = signupAndGetToken("likeA@example.com"); + String tokenB = signupAndGetToken("likeB@example.com"); + UUID seriesA = UUID.randomUUID(); + UUID seriesB = UUID.randomUUID(); + + like(tokenA, seriesA); + like(tokenB, seriesB); + + mockMvc + .perform(get("/user-progress/likes").header("Authorization", "Bearer " + tokenB)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0]").value(seriesB.toString())); + } + + @Test + void likeEndpointsRequireAuth() throws Exception { + UUID series = UUID.randomUUID(); + mockMvc.perform(get("/user-progress/likes")).andExpect(status().is4xxClientError()); + mockMvc.perform(put("/user-progress/likes/" + series)).andExpect(status().is4xxClientError()); + mockMvc + .perform(delete("/user-progress/likes/" + series)) + .andExpect(status().is4xxClientError()); + } +} diff --git a/web-client/src/App.tsx b/web-client/src/App.tsx index 61d6adf..a44cd7c 100644 --- a/web-client/src/App.tsx +++ b/web-client/src/App.tsx @@ -3,6 +3,7 @@ import { GuestRoute } from './components/GuestRoute' import { ProtectedRoute } from './components/ProtectedRoute' import { LibraryPage } from './pages/LibraryPage' import { LoginPage } from './pages/LoginPage' +import { MyShowsPage } from './pages/MyShowsPage' import { SeriesDetailPage } from './pages/SeriesDetailPage' import { SignupPage } from './pages/SignupPage' @@ -33,6 +34,14 @@ function App() { } /> + + + + } + /> ({ useAuth: vi.fn() })) vi.mock('../api/catalog', () => ({ listSeries: vi.fn(), listSeriesEpisodes: vi.fn() })) vi.mock('../api/progress', () => ({ getProgress: vi.fn(), updateProgress: vi.fn() })) +vi.mock('../api/likes', () => ({ + getLikes: vi.fn().mockResolvedValue([]), + likeSeries: vi.fn(), + unlikeSeries: vi.fn(), +})) import { useAuth } from '../context/useAuth' import { listSeries } from '../api/catalog' diff --git a/web-client/src/api/likes.ts b/web-client/src/api/likes.ts new file mode 100644 index 0000000..f461c62 --- /dev/null +++ b/web-client/src/api/likes.ts @@ -0,0 +1,22 @@ +import { authHeaders, fetchJson } from './client' + +/** The current user's liked series ids ("My Shows"), most recently liked first. */ +export function getLikes(token: string): Promise { + return fetchJson('/user-progress/likes', { + headers: authHeaders(token), + }) +} + +export function likeSeries(token: string, seriesId: string): Promise { + return fetchJson(`/user-progress/likes/${seriesId}`, { + method: 'PUT', + headers: authHeaders(token), + }) +} + +export function unlikeSeries(token: string, seriesId: string): Promise { + return fetchJson(`/user-progress/likes/${seriesId}`, { + method: 'DELETE', + headers: authHeaders(token), + }) +} diff --git a/web-client/src/components/AppHeader.tsx b/web-client/src/components/AppHeader.tsx index 8101b3b..fb7eb2b 100644 --- a/web-client/src/components/AppHeader.tsx +++ b/web-client/src/components/AppHeader.tsx @@ -7,9 +7,17 @@ export function AppHeader() { return (
- - SceneIt - +
+ + SceneIt + + + Browse + + + My Shows + +
{user?.email} + )} +
) } diff --git a/web-client/src/components/SeriesGrid.tsx b/web-client/src/components/SeriesGrid.tsx new file mode 100644 index 0000000..aab410b --- /dev/null +++ b/web-client/src/components/SeriesGrid.tsx @@ -0,0 +1,29 @@ +import type { SeriesSummary } from '../api/catalog' +import { SeriesCard } from './SeriesCard' + +/** Responsive grid of series cards with like + progress state. */ +export function SeriesGrid({ + series, + progressBySeries, + likedIds, + onToggleLike, +}: { + series: SeriesSummary[] + progressBySeries: Record + likedIds: Set + onToggleLike: (seriesId: string) => void +}) { + return ( +
+ {series.map((s) => ( + + ))} +
+ ) +} diff --git a/web-client/src/hooks/__tests__/useLikes.test.ts b/web-client/src/hooks/__tests__/useLikes.test.ts new file mode 100644 index 0000000..d83b705 --- /dev/null +++ b/web-client/src/hooks/__tests__/useLikes.test.ts @@ -0,0 +1,93 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../api/likes', () => ({ + getLikes: vi.fn(), + likeSeries: vi.fn(), + unlikeSeries: vi.fn(), +})) + +import { getLikes, likeSeries, unlikeSeries } from '../../api/likes' +import { useLikes } from '../useLikes' + +const mockGetLikes = vi.mocked(getLikes) +const mockLike = vi.mocked(likeSeries) +const mockUnlike = vi.mocked(unlikeSeries) + +afterEach(() => vi.clearAllMocks()) + +describe('useLikes', () => { + it('loads liked ids in the order the API returns them (newest first)', async () => { + mockGetLikes.mockResolvedValue(['a', 'b']) + const { result } = renderHook(() => useLikes('tok')) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.orderedLikedIds).toEqual(['a', 'b']) + expect(result.current.likedIds.has('a')).toBe(true) + }) + + it('surfaces an error status when the load fails', async () => { + mockGetLikes.mockRejectedValue(new Error('down')) + const { result } = renderHook(() => useLikes('tok')) + + await waitFor(() => expect(result.current.status).toBe('error')) + }) + + it('optimistically prepends a new like and calls the API', async () => { + mockGetLikes.mockResolvedValue(['a']) + mockLike.mockResolvedValue(undefined) + const { result } = renderHook(() => useLikes('tok')) + await waitFor(() => expect(result.current.status).toBe('ready')) + + act(() => result.current.toggleLike('c')) + + expect(result.current.orderedLikedIds).toEqual(['c', 'a']) + expect(mockLike).toHaveBeenCalledWith('tok', 'c') + }) + + it('optimistically removes an unlike', async () => { + mockGetLikes.mockResolvedValue(['a', 'b']) + mockUnlike.mockResolvedValue(undefined) + const { result } = renderHook(() => useLikes('tok')) + await waitFor(() => expect(result.current.status).toBe('ready')) + + act(() => result.current.toggleLike('a')) + + expect(result.current.orderedLikedIds).toEqual(['b']) + expect(mockUnlike).toHaveBeenCalledWith('tok', 'a') + }) + + it('reverts a failed toggle without clobbering a concurrent toggle of another series', async () => { + mockGetLikes.mockResolvedValue([]) + let rejectA: (e: unknown) => void = () => {} + // Request for 'A' hangs (we reject it later); request for 'B' succeeds immediately. + mockLike.mockImplementation((_t, id) => + id === 'A' ? new Promise((_, r) => (rejectA = r)) : Promise.resolve(), + ) + const { result } = renderHook(() => useLikes('tok')) + await waitFor(() => expect(result.current.status).toBe('ready')) + + act(() => result.current.toggleLike('A')) // optimistic ['A'], request A pending + act(() => result.current.toggleLike('B')) // optimistic ['B','A'], request B resolves + expect(result.current.orderedLikedIds).toEqual(['B', 'A']) + + await act(async () => { + rejectA(new Error('A failed')) + }) + // A reverts out; B — which succeeded — must survive. + await waitFor(() => expect(result.current.orderedLikedIds).toEqual(['B'])) + }) + + it('ignores repeat toggles for a series while its request is in flight', async () => { + mockGetLikes.mockResolvedValue([]) + mockLike.mockReturnValue(new Promise(() => {})) // never settles + const { result } = renderHook(() => useLikes('tok')) + await waitFor(() => expect(result.current.status).toBe('ready')) + + act(() => result.current.toggleLike('c')) + act(() => result.current.toggleLike('c')) // dropped by the in-flight guard + + expect(mockLike).toHaveBeenCalledTimes(1) + expect(mockUnlike).not.toHaveBeenCalled() + }) +}) diff --git a/web-client/src/hooks/useLikes.ts b/web-client/src/hooks/useLikes.ts new file mode 100644 index 0000000..68b266a --- /dev/null +++ b/web-client/src/hooks/useLikes.ts @@ -0,0 +1,61 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { getLikes, likeSeries, unlikeSeries } from '../api/likes' + +type Status = 'loading' | 'ready' | 'error' + +/** + * Loads the current user's liked series (newest first) and exposes an optimistic toggle. + * + * - `orderedLikedIds` is the source of truth, kept newest-liked-first so "My Shows" can render in + * like order; `likedIds` is the derived Set for O(1) membership checks on cards. + * - Toggles are optimistic and revert to the exact prior order on failure. + * - A per-series in-flight guard drops repeat toggles until the outstanding request settles, so a + * fast double-tap can't desync the client from the server via reordered PUT/DELETE. + */ +export function useLikes(token: string | null) { + const [orderedLikedIds, setOrderedLikedIds] = useState([]) + const [status, setStatus] = useState('loading') + const inFlight = useRef>(new Set()) + + useEffect(() => { + if (!token) return + let cancelled = false + setStatus('loading') + getLikes(token) + .then((ids) => { + if (cancelled) return + setOrderedLikedIds(ids) + setStatus('ready') + }) + .catch(() => { + if (!cancelled) setStatus('error') + }) + return () => { + cancelled = true + } + }, [token]) + + const likedIds = useMemo(() => new Set(orderedLikedIds), [orderedLikedIds]) + + function toggleLike(seriesId: string) { + if (!token) return + if (inFlight.current.has(seriesId)) return // a request for this series is still settling + + const wasLiked = likedIds.has(seriesId) + inFlight.current.add(seriesId) + + // Newly liked series go to the front (newest first); unlikes drop out. Both apply and revert + // are functional updates that touch only this id, so a failed revert can't clobber a + // concurrent toggle of another series. + const add = (ids: string[]) => (ids.includes(seriesId) ? ids : [seriesId, ...ids]) + const remove = (ids: string[]) => ids.filter((id) => id !== seriesId) + + setOrderedLikedIds(wasLiked ? remove : add) // optimistic + const request = wasLiked ? unlikeSeries(token, seriesId) : likeSeries(token, seriesId) + request + .catch(() => setOrderedLikedIds(wasLiked ? add : remove)) // revert: the inverse op + .finally(() => inFlight.current.delete(seriesId)) + } + + return { likedIds, orderedLikedIds, status, toggleLike } +} diff --git a/web-client/src/hooks/useSeriesWithProgress.ts b/web-client/src/hooks/useSeriesWithProgress.ts new file mode 100644 index 0000000..b91da83 --- /dev/null +++ b/web-client/src/hooks/useSeriesWithProgress.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react' +import { listSeries, type SeriesSummary } from '../api/catalog' +import { getProgress } from '../api/progress' + +type Status = 'loading' | 'ready' | 'error' + +/** Loads the catalog plus the current user's watch progress, keyed by series id. */ +export function useSeriesWithProgress(token: string | null) { + const [series, setSeries] = useState([]) + const [progressBySeries, setProgressBySeries] = useState>({}) + const [status, setStatus] = useState('loading') + + useEffect(() => { + if (!token) return + let cancelled = false + setStatus('loading') + + Promise.all([listSeries(), getProgress(token)]) + .then(([seriesList, progress]) => { + if (cancelled) return + const byId: Record = {} + for (const entry of progress) byId[entry.seriesId] = entry.episodeIndex + setSeries(seriesList) + setProgressBySeries(byId) + setStatus('ready') + }) + .catch(() => { + if (!cancelled) setStatus('error') + }) + + return () => { + cancelled = true + } + }, [token]) + + return { series, progressBySeries, status } +} diff --git a/web-client/src/pages/LibraryPage.tsx b/web-client/src/pages/LibraryPage.tsx index 4927ff0..90a3341 100644 --- a/web-client/src/pages/LibraryPage.tsx +++ b/web-client/src/pages/LibraryPage.tsx @@ -1,38 +1,13 @@ -import { useEffect, useState } from 'react' -import { listSeries, type SeriesSummary } from '../api/catalog' -import { getProgress } from '../api/progress' import { AppHeader } from '../components/AppHeader' -import { SeriesCard } from '../components/SeriesCard' +import { SeriesGrid } from '../components/SeriesGrid' import { useAuth } from '../context' +import { useLikes } from '../hooks/useLikes' +import { useSeriesWithProgress } from '../hooks/useSeriesWithProgress' export function LibraryPage() { const { token } = useAuth() - const [series, setSeries] = useState([]) - const [progressBySeries, setProgressBySeries] = useState>({}) - const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') - - useEffect(() => { - if (!token) return - let cancelled = false - setStatus('loading') - - Promise.all([listSeries(), getProgress(token)]) - .then(([seriesList, progress]) => { - if (cancelled) return - const byId: Record = {} - for (const entry of progress) byId[entry.seriesId] = entry.episodeIndex - setSeries(seriesList) - setProgressBySeries(byId) - setStatus('ready') - }) - .catch(() => { - if (!cancelled) setStatus('error') - }) - - return () => { - cancelled = true - } - }, [token]) + const { series, progressBySeries, status } = useSeriesWithProgress(token) + const { likedIds, toggleLike } = useLikes(token) return (
@@ -51,11 +26,12 @@ export function LibraryPage() {

No series in the catalog yet.

)} {status === 'ready' && series.length > 0 && ( -
- {series.map((s) => ( - - ))} -
+ )}
diff --git a/web-client/src/pages/MyShowsPage.tsx b/web-client/src/pages/MyShowsPage.tsx new file mode 100644 index 0000000..261c16b --- /dev/null +++ b/web-client/src/pages/MyShowsPage.tsx @@ -0,0 +1,56 @@ +import { useMemo } from 'react' +import type { SeriesSummary } from '../api/catalog' +import { AppHeader } from '../components/AppHeader' +import { SeriesGrid } from '../components/SeriesGrid' +import { useAuth } from '../context' +import { useLikes } from '../hooks/useLikes' +import { useSeriesWithProgress } from '../hooks/useSeriesWithProgress' + +export function MyShowsPage() { + const { token } = useAuth() + const { series, progressBySeries, status: seriesStatus } = useSeriesWithProgress(token) + const { likedIds, orderedLikedIds, status: likesStatus, toggleLike } = useLikes(token) + + // Render in like-order (newest first), hydrating each liked id from the catalog. + const seriesById = useMemo(() => new Map(series.map((s) => [s.id, s])), [series]) + const likedSeries = orderedLikedIds + .map((id) => seriesById.get(id)) + .filter((s): s is SeriesSummary => s !== undefined) + + // The page needs BOTH the catalog and the likes list; showing the empty state before likes + // have loaded would falsely tell a user with likes that they have none. + const status = + seriesStatus === 'error' || likesStatus === 'error' + ? 'error' + : seriesStatus === 'ready' && likesStatus === 'ready' + ? 'ready' + : 'loading' + + return ( +
+ +
+

My Shows

+

Series you've liked.

+ + {status === 'loading' &&

Loading…

} + {status === 'error' && ( +

Couldn't load your shows. Please try again.

+ )} + {status === 'ready' && likedSeries.length === 0 && ( +

+ You haven't liked any shows yet. Tap the ♡ on a series to add it here. +

+ )} + {status === 'ready' && likedSeries.length > 0 && ( + + )} +
+
+ ) +} diff --git a/web-client/src/pages/SeriesDetailPage.tsx b/web-client/src/pages/SeriesDetailPage.tsx index 78e690b..1b66772 100644 --- a/web-client/src/pages/SeriesDetailPage.tsx +++ b/web-client/src/pages/SeriesDetailPage.tsx @@ -8,10 +8,13 @@ import { EpisodeRow } from '../components/EpisodeRow' import { ProgressBanner } from '../components/ProgressBanner' import { SeasonTabs } from '../components/SeasonTabs' import { useAuth } from '../context' +import { useLikes } from '../hooks/useLikes' export function SeriesDetailPage() { const { id } = useParams<{ id: string }>() const { token } = useAuth() + const { likedIds, toggleLike } = useLikes(token) + const liked = id ? likedIds.has(id) : false const [series, setSeries] = useState(null) const [episodes, setEpisodes] = useState([]) const [progress, setProgress] = useState(0) @@ -69,13 +72,30 @@ export function SeriesDetailPage() { {status === 'ready' && id && token && ( <> -
-

{series?.title ?? 'Series'}

- {series && ( -

- {series.seasonsCount} seasons · {series.episodesCount} episodes -

- )} +
+
+

{series?.title ?? 'Series'}

+ {series && ( +

+ {series.seasonsCount} seasons · {series.episodesCount} episodes +

+ )} +
+
diff --git a/web-client/src/pages/__tests__/LibraryPage.test.tsx b/web-client/src/pages/__tests__/LibraryPage.test.tsx index c678f73..fc54058 100644 --- a/web-client/src/pages/__tests__/LibraryPage.test.tsx +++ b/web-client/src/pages/__tests__/LibraryPage.test.tsx @@ -6,6 +6,11 @@ import { LibraryPage } from '../LibraryPage' vi.mock('../../context/useAuth', () => ({ useAuth: vi.fn() })) vi.mock('../../api/catalog', () => ({ listSeries: vi.fn() })) vi.mock('../../api/progress', () => ({ getProgress: vi.fn() })) +vi.mock('../../api/likes', () => ({ + getLikes: vi.fn().mockResolvedValue([]), + likeSeries: vi.fn(), + unlikeSeries: vi.fn(), +})) import { useAuth } from '../../context/useAuth' import { listSeries } from '../../api/catalog' diff --git a/web-client/src/pages/__tests__/MyShowsPage.test.tsx b/web-client/src/pages/__tests__/MyShowsPage.test.tsx new file mode 100644 index 0000000..a23f089 --- /dev/null +++ b/web-client/src/pages/__tests__/MyShowsPage.test.tsx @@ -0,0 +1,95 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MyShowsPage } from '../MyShowsPage' + +vi.mock('../../context/useAuth', () => ({ useAuth: vi.fn() })) +vi.mock('../../api/catalog', () => ({ listSeries: vi.fn() })) +vi.mock('../../api/progress', () => ({ getProgress: vi.fn() })) +vi.mock('../../api/likes', () => ({ + getLikes: vi.fn(), + likeSeries: vi.fn(), + unlikeSeries: vi.fn(), +})) + +import { useAuth } from '../../context/useAuth' +import { listSeries } from '../../api/catalog' +import { getProgress } from '../../api/progress' +import { getLikes } from '../../api/likes' + +const mockUseAuth = vi.mocked(useAuth) +const mockListSeries = vi.mocked(listSeries) +const mockGetProgress = vi.mocked(getProgress) +const mockGetLikes = vi.mocked(getLikes) + +afterEach(() => vi.clearAllMocks()) + +const series = (id: string, title: string) => ({ id, title, seasonsCount: 1, episodesCount: 10 }) + +function setup() { + mockUseAuth.mockReturnValue({ + user: { id: '1', email: 'demo@example.com' }, + token: 'tok', + isLoading: false, + login: vi.fn(), + signup: vi.fn(), + logout: vi.fn(), + }) + return render( + + + , + ) +} + +describe('MyShowsPage', () => { + it('renders only liked series, in like-order (newest first) not catalog order', async () => { + // Catalog order is Alpha, Beta, Gamma; likes are Gamma then Alpha. + mockListSeries.mockResolvedValue([ + series('A', 'Alpha'), + series('B', 'Beta'), + series('C', 'Gamma'), + ]) + mockGetProgress.mockResolvedValue([]) + mockGetLikes.mockResolvedValue(['C', 'A']) + + setup() + + await screen.findByText('Gamma') + const titles = screen.getAllByTestId('series-title').map((el) => el.textContent) + expect(titles).toEqual(['Gamma', 'Alpha']) // like-order, and Beta (unliked) absent + }) + + it('shows the empty state only once likes have loaded', async () => { + mockListSeries.mockResolvedValue([series('A', 'Alpha')]) + mockGetProgress.mockResolvedValue([]) + // Likes never resolve — the page must stay in "loading", never flash the empty message. + mockGetLikes.mockReturnValue(new Promise(() => {})) + + setup() + + await waitFor(() => expect(mockGetLikes).toHaveBeenCalled()) + expect(screen.getByText('Loading…')).toBeInTheDocument() + expect(screen.queryByText(/haven't liked any shows/i)).not.toBeInTheDocument() + }) + + it('shows the empty message when the user has no likes', async () => { + mockListSeries.mockResolvedValue([series('A', 'Alpha')]) + mockGetProgress.mockResolvedValue([]) + mockGetLikes.mockResolvedValue([]) + + setup() + + expect(await screen.findByText(/haven't liked any shows/i)).toBeInTheDocument() + }) + + it('shows an error message when the likes call fails', async () => { + mockListSeries.mockResolvedValue([series('A', 'Alpha')]) + mockGetProgress.mockResolvedValue([]) + mockGetLikes.mockRejectedValue(new Error('likes down')) + + setup() + + expect(await screen.findByText(/couldn't load/i)).toBeInTheDocument() + }) +}) diff --git a/web-client/src/pages/__tests__/SeriesDetailPage.test.tsx b/web-client/src/pages/__tests__/SeriesDetailPage.test.tsx index e142092..37688b4 100644 --- a/web-client/src/pages/__tests__/SeriesDetailPage.test.tsx +++ b/web-client/src/pages/__tests__/SeriesDetailPage.test.tsx @@ -8,6 +8,11 @@ vi.mock('../../context/useAuth', () => ({ useAuth: vi.fn() })) vi.mock('../../api/catalog', () => ({ listSeries: vi.fn(), listSeriesEpisodes: vi.fn() })) vi.mock('../../api/progress', () => ({ getProgress: vi.fn(), updateProgress: vi.fn() })) vi.mock('../../api/chat', () => ({ askQuestion: vi.fn() })) +vi.mock('../../api/likes', () => ({ + getLikes: vi.fn().mockResolvedValue([]), + likeSeries: vi.fn(), + unlikeSeries: vi.fn(), +})) import { useAuth } from '../../context/useAuth' import { listSeries, listSeriesEpisodes } from '../../api/catalog'