Plan: Watch History¶
Overview¶
Status: Approved Author: nguyenhuuca Date: 2026-05-31 Related PRD: PRD-watch-history Related ADR: ADR-0013: Watch History Design Related Spec: Spec: Watch History
Objective¶
Implement watch history end-to-end: auto-record on play, /history page replacing ComingSoon, watched indicator on video cards, delete/clear operations — all scoped to authenticated users.
Scope¶
In Scope¶
- Liquibase migration:
watch_historytable - Backend: entity, repository, service (upsert + auto-evict), controller, DTOs
- Frontend:
watchHistory.jsAPI module, hooks,WatchedBadgecomponent,HistoryPage, AppShell wiring
Out of Scope¶
- Resume playback (watch timestamp)
- Recommendations based on history
- Guest user history
Technical Approach¶
Architecture¶
[VideoSwiper — on play event]
↓ fire-and-forget (no await)
[api/watchHistory.js → POST /watch-history]
↓
[WatchHistoryController]
[WatchHistoryService — upsert + auto-evict]
[WatchHistoryRepository]
↓
[PostgreSQL: watch_history table]
[Page load]
↓
[GET /watch-history/ids → useWatchedIds hook → Set<Long>]
↓
[VideoSwiper card → WatchedBadge — Set.has(video.id)]
[AppShell activeNav === 'history']
↓
[HistoryPage → useWatchHistory hook → GET /watch-history]
Key Decisions (from ADR-0013 + Spec)¶
| Decision | Choice |
|---|---|
| Recording trigger | Frontend fire-and-forget POST on play |
| Cap strategy | Auto-evict oldest (sliding window, 500 entries) |
| Re-watch | Upsert — update watched_at, move to top |
| Invalid videoId | 200 silent ignore (not 404) |
| State delivery | GET /ids at page load → client-side Set.has() |
| Uniqueness | UNIQUE(user_id, source_video_id) — stable under ON DELETE SET NULL |
AppShell Change¶
Current catch-all (renders ComingSoon for all non-home nav):
Change to add history condition before catch-all:
} : activeNav === 'history' ? (
<HistoryPage />
) : activeNav !== 'home' ? (
<ComingSoon page={activeNav} />
Implementation Steps¶
Phase 1: Database Migration (0.5 day)¶
-
[ ] 1.1 Create
api/src/main/resources/db/changelog/sql/202605310002-create-watch-history-table.sqlCREATE TABLE watch_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, video_id BIGINT REFERENCES video_sources(id) ON DELETE SET NULL, source_video_id BIGINT NOT NULL, watched_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uq_watch_history_user_video UNIQUE (user_id, source_video_id) ); CREATE INDEX idx_watch_history_user_watched ON watch_history(user_id, watched_at DESC); -
[ ] 1.2 Register in
api/src/main/resources/db/changelog/db.changelog-master.yaml
Phase 2: Backend Entity (0.5 day)¶
- [ ] 2.1 Create
api/.../entity/WatchHistory.java - Fields:
UUID id,@ManyToOne(LAZY) User user,@ManyToOne(LAZY, optional=true) VideoSource video,Long sourceVideoId(@Column(updatable=false)),Instant watchedAt(@CreationTimestamp) @Table(name="watch_history", uniqueConstraints = @UniqueConstraint(columnNames={"user_id","source_video_id"}))- Lombok:
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
Phase 3: Backend Repository (0.5 day)¶
- [ ] 3.1 Create
api/.../repo/WatchHistoryRepository.javaextends JpaRepository<WatchHistory, UUID> @Query("SELECT w.sourceVideoId FROM WatchHistory w WHERE w.user.id = :userId") List<Long> findSourceVideoIdsByUserId(Long userId); List<WatchHistory> findByUserIdOrderByWatchedAtDesc(Long userId); Optional<WatchHistory> findByUserIdAndSourceVideoId(Long userId, Long sourceVideoId); long countByUserId(Long userId); @Query("SELECT w FROM WatchHistory w WHERE w.user.id = :userId ORDER BY w.watchedAt ASC LIMIT 1") Optional<WatchHistory> findOldestByUserId(Long userId); void deleteByUserIdAndSourceVideoId(Long userId, Long sourceVideoId); void deleteByUserId(Long userId);
Phase 4: DTOs (0.5 day)¶
- [ ] 4.1 Create in
api/.../dto/: WatchHistoryDto—{ id: UUID, sourceVideoId: Long, videoId: Long|null, title: String|null, poster: String|null, watchedAt: Instant }WatchHistoryIdsDto—{ videoIds: List<Long> }RecordWatchRequest—{ videoId: Long }with@NotNull
Phase 5: Backend Service (1 day)¶
- [ ] 5.1 Create
api/.../service/impl/WatchHistoryService.java @Service @RequiredArgsConstructor @Slf4j-
Auth helper:
SecurityContextHolder.getContext().getAuthentication().getDetails()→UserDetailDto -
[ ] 5.2 Implement methods:
@Transactional(readOnly = true) WatchHistoryIdsDto getWatchedIds() // → SELECT source_video_id WHERE user_id = current @Transactional(readOnly = true) List<WatchHistoryDto> getHistory() // → findByUserIdOrderByWatchedAtDesc @Transactional WatchHistoryDto recordWatch(Long videoId) // 1. Check if entry exists for (userId, videoId) // 2a. If exists → update watchedAt = now(), return // 2b. If new → check count >= 500 → if yes, delete oldest → insert new @Transactional void removeEntry(Long videoId) // deleteByUserIdAndSourceVideoId — no-op if not found (idempotent) @Transactional void clearAll() // deleteByUserId -
[ ] 5.3
recordWatchmust NOT throw ifvideoIdnot found invideo_sources— setvideo = null,sourceVideoId = videoId, persist silently
Phase 6: Backend Controller (0.5 day)¶
-
[ ] 6.1 Create
api/.../web/WatchHistoryController.java@RestController @RequestMapping(AppConstant.API.BASE_URL + "/watch-history") // No @AuditLog — POST volume too high per Spec @GetMapping("/ids") ResultObjectInfo<WatchHistoryIdsDto> getIds() @GetMapping ResultListInfo<WatchHistoryDto> getHistory() @PostMapping @RateLimited(permit = 30) ResponseEntity<ResultObjectInfo<WatchHistoryDto>> record(@Valid @RequestBody RecordWatchRequest req) // Returns 201 for new, 200 for upsert @DeleteMapping ResponseEntity<Void> remove(@RequestParam Long videoId) // 204 always (idempotent) @DeleteMapping("/all") ResponseEntity<Void> clearAll() // 204 -
[ ] 6.2 Confirm
/v1/funny-app/watch-history/**is NOT inJWTAuthenticationFilter.shouldNotFilter()whitelist
Phase 7: Backend Tests (1 day)¶
- [ ] 7.1
WatchHistoryServiceTest.java recordWatch: new entry → 201; re-watch → 200 + watched_at updatedrecordWatch: at cap 500, new video → oldest evicted, count stays 500recordWatch: at cap 500, re-watch existing → no eviction, upsert onlyrecordWatch: invalid videoId → 200, entry saved withvideoId = nullremoveEntry: non-existent → no exception-
clearAll: all entries removed for current user only -
[ ] 7.2
WatchHistoryControllerTest.java - Unauthenticated
GET /ids→ 401 POST /watch-history→ 201; same video again → 200DELETE /watch-history?videoId=X(not in history) → 204-
DELETE /watch-history/all→ 204; subsequentGET→ empty list -
[ ] 7.3 Run
mvn verify, updateapi/.coverage-threshold(+1%)
Phase 8: Frontend API Module (0.5 day)¶
- [ ] 8.1 Create
webapp/src/api/watchHistory.jsimport { api } from './client' export const watchHistoryApi = { ids: () => api.get('/watch-history/ids'), list: () => api.get('/watch-history'), record: (videoId) => api.post('/watch-history', { videoId }), remove: (videoId) => api.delete(`/watch-history?videoId=${videoId}`), clearAll: () => api.delete('/watch-history/all'), }
Phase 9: Frontend Hooks (0.5 day)¶
-
[ ] 9.1 Create
webapp/src/hooks/useWatchHistory.jsexport function useWatchedIds() { const { data } = useQuery({ queryKey: ['watchHistory', 'ids'], queryFn: () => watchHistoryApi.ids(), staleTime: Infinity, enabled: !!localStorage.getItem('jwt'), }) return new Set(data?.videoIds ?? []) } export function useWatchHistoryList() { return useQuery({ queryKey: ['watchHistory', 'list'], queryFn: () => watchHistoryApi.list(), enabled: !!localStorage.getItem('jwt'), }) } -
[ ] 9.2 Create
webapp/src/hooks/useRecordWatch.js
Phase 10: WatchedBadge + VideoSwiper (0.5 day)¶
- [ ] 10.1 Create
webapp/src/components/video/WatchedBadge.jsx - Small overlay badge: eye icon (
visibility) or checkmark with "Watched" label - Positioned top-left of video card (absolute, inside
.video-cardcontainer) -
Only renders when
isWatched === true -
[ ] 10.2 In
VideoSwiper.jsx: - Import
useWatchedIdsanduseRecordWatch - On play event: call
recordWatch(video.id)(fire-and-forget) - Render
<WatchedBadge isWatched={watchedIds.has(video.id)} />on card
Phase 11: HistoryPage + AppShell (1 day)¶
- [ ] 11.1 Create
webapp/src/pages/HistoryPage.jsx - Uses
useWatchHistoryList()for data - Grid layout of history cards, newest first
- Each card: poster, title,
watchedAtrelative timestamp, remove button - Handle
videoId: null→ show "Video no longer available" placeholder - "Clear all" button → confirmation dialog →
watchHistoryApi.clearAll()→ invalidate queries - Empty state: "No watch history yet — play a video to get started"
-
Loading skeleton
-
[ ] 11.2 Modify
webapp/src/components/layout/AppShell.jsx - Import
HistoryPage - Add condition before the
activeNav !== 'home'catch-all:
Files to Create / Modify¶
| File | Action | Description |
|---|---|---|
api/.../db/changelog/sql/202605310002-create-watch-history-table.sql |
Create | DB migration |
api/.../db/changelog/db.changelog-master.yaml |
Modify | Register migration |
api/.../entity/WatchHistory.java |
Create | JPA entity |
api/.../repo/WatchHistoryRepository.java |
Create | JPA repository |
api/.../dto/WatchHistoryDto.java |
Create | Response DTO |
api/.../dto/WatchHistoryIdsDto.java |
Create | Response DTO |
api/.../dto/RecordWatchRequest.java |
Create | Request DTO |
api/.../service/impl/WatchHistoryService.java |
Create | Service |
api/.../web/WatchHistoryController.java |
Create | REST controller |
webapp/src/api/watchHistory.js |
Create | API module |
webapp/src/hooks/useWatchHistory.js |
Create | React Query hooks |
webapp/src/hooks/useRecordWatch.js |
Create | Fire-and-forget helper |
webapp/src/components/video/WatchedBadge.jsx |
Create | Watched indicator |
webapp/src/components/video/VideoSwiper.jsx |
Modify | Fire record + WatchedBadge |
webapp/src/pages/HistoryPage.jsx |
Create | History list page |
webapp/src/components/layout/AppShell.jsx |
Modify | Replace ComingSoon for history |
Testing Strategy¶
Unit Tests¶
| Component | Test Cases |
|---|---|
WatchHistoryService.recordWatch |
New entry (201), re-watch (200+upsert), cap eviction, invalid videoId silent |
WatchHistoryService.removeEntry |
Remove existing, remove non-existent (no-op) |
WatchHistoryService.clearAll |
Removes only current user's entries |
Integration Tests¶
| Scenario | Expected |
|---|---|
| Unauthenticated POST | 401 |
| POST same videoId twice | First 201, second 200, one DB entry |
| POST at cap 500 (new video) | Oldest evicted, count = 500 |
| POST at cap 500 (re-watch) | No eviction, upsert only |
| DELETE non-existent videoId | 204 |
| DELETE /all → GET | Empty list |
Manual Testing¶
- [ ] Play video → watched indicator appears on card immediately
- [ ] Refresh page → indicator still shown (persisted via
/ids) - [ ] Open
/history(side nav) → video appears with timestamp - [ ] Re-watch → entry moves to top of history list
- [ ] Delete single entry → removed from history, indicator clears on card
- [ ] Clear all → confirm dialog → history empty
- [ ] Deleted video in history → shows "Video no longer available" placeholder, no crash
Dependency Graph¶
Phase 1 (Migration)
└── Phase 2 (Entity)
└── Phase 3 (Repository)
└── Phase 4 (DTOs) ─────────────┐
└── Phase 5 (Service) ─────┤
└── Phase 6 (Controller)
└── Phase 7 (BE Tests)
Phase 8 (API module) ← after Phase 6 contract stable
└── Phase 9 (Hooks)
├── Phase 10 (WatchedBadge + VideoSwiper)
└── Phase 11 (HistoryPage + AppShell)
Backend phases 1–7 must complete before end-to-end frontend testing. Frontend phases 8–11 can start once the API contract is finalised (Phase 6).
Rollback Plan¶
- Revert
AppShell.jsx(restore<ComingSoon page="history" />catch-all) - Revert
VideoSwiper.jsx(remove fire-and-forget call and WatchedBadge) - Drop migration:
DROP TABLE watch_history; - Remove all new files (entity, repo, service, controller, DTOs, frontend modules)
- No data loss to existing features — fully additive
Risks¶
| Risk | Mitigation |
|---|---|
source_video_id not set before save |
Set sourceVideoId = videoId in service before persist; @Column(updatable=false) prevents accidental clear |
| Auto-evict + insert not atomic | Wrap in @Transactional — both DELETE oldest and INSERT new run in same transaction |
| Fire-and-forget fails silently | Acceptable per Spec; log warning server-side for monitoring |
| WatchedBadge renders for unauthenticated users | Guard with AuthContext.user check — render null if not logged in |
| N+1 on history list (loading User/VideoSource per entry) | Use @EntityGraph or JOIN FETCH in repo; test with 100+ entries |
Checklist¶
Before Starting¶
- [x] PRD, ADR, Spec approved
- [x] Branch: create
feat/watch-historyfrom main
Before PR¶
- [ ]
mvn verifypasses (tests + coverage gate) - [ ]
npm run lintpasses - [ ]
npm run testpasses - [ ] Manual test checklist complete
- [ ]
videoId: nullentry renders gracefully — no crash - [ ] Fire-and-forget confirmed: POST does not block video playback
Before Merge¶
- [ ] Code review approved
- [ ] Coverage threshold updated in
api/.coverage-threshold - [ ] Migration reviewed
Beads¶
# Epic
bd create --title="Watch History" --type=feature --priority=2
# Backend
bd create --title="[BE-1] DB migration: watch_history table" --type=task
bd create --title="[BE-2] WatchHistory entity" --type=task
bd create --title="[BE-3] WatchHistoryRepository" --type=task
bd create --title="[BE-4] Watch history DTOs" --type=task
bd create --title="[BE-5] WatchHistoryService (upsert + auto-evict)" --type=task
bd create --title="[BE-6] WatchHistoryController" --type=task
bd create --title="[BE-7] Backend tests + coverage" --type=task
# Frontend
bd create --title="[FE-1] api/watchHistory.js API module" --type=task
bd create --title="[FE-2] useWatchHistory + useRecordWatch hooks" --type=task
bd create --title="[FE-3] WatchedBadge + VideoSwiper fire-and-forget" --type=task
bd create --title="[FE-4] HistoryPage + AppShell wiring" --type=task
# Dependencies
# BE-2→BE-1, BE-3→BE-2, BE-5→BE-3+BE-4, BE-6→BE-5, BE-7→BE-6
# FE-2→FE-1, FE-3→FE-2, FE-4→FE-2
Progress Log¶
| Date | Update |
|---|---|
| 2026-05-31 | Plan created from Spec + ADR-0013 |