diff --git a/docs/killrvideo_openapi.yaml b/docs/killrvideo_openapi.yaml
index 6fc07ed..64e761b 100644
--- a/docs/killrvideo_openapi.yaml
+++ b/docs/killrvideo_openapi.yaml
@@ -262,7 +262,11 @@ paths:
\ Authenticated *viewer*-level callers receive 403.\n \u2013 The owner\
\ (*creator*) or any *moderator* can still access \u2192 404 to\n remain\
\ consistent with current spec (not explicitly tested yet).\n\u2022 A READY\
- \ video is public: anyone can record a view (204)."
+ \ video is public: anyone can record a view (204).\n\nActivity logging: When\
+ \ an authenticated user records a view, the backend **must** write a row to\
+ \ the `user_activity` table with `activity_type = 'view'`, including the caller's\
+ \ `userid`, the `videoid`, and an `activity_timestamp` (timeuuid). Unauthenticated\
+ \ views increment the counter only and do not produce an activity row."
operationId: record_view_api_v1_videos_id__video_id_path__view_post
security:
- OAuth2PasswordBearer: []
@@ -714,7 +718,10 @@ paths:
tags:
- Comments & Ratings
summary: Add comment to video
- description: Endpoint for viewers to add a comment to a READY video.
+ description: "Endpoint for viewers to add a comment to a READY video.\n\nActivity\
+ \ logging: On success the backend **must** write a row to the `user_activity`\
+ \ table with `activity_type = 'comment'`, including the caller's `userid`,\
+ \ the `videoid`, and an `activity_timestamp` (timeuuid)."
operationId: post_comment_to_video_api_v1_videos__video_id_path__comments_post
security:
- OAuth2PasswordBearer: []
@@ -845,7 +852,11 @@ paths:
tags:
- Comments & Ratings
summary: Rate a video (create or update)
- description: Upsert a rating (1-5) for the specified video by the current viewer.
+ description: "Upsert a rating (1-5) for the specified video by the current viewer.\n\
+ \nActivity logging: On success the backend **must** write a row to the `user_activity`\
+ \ table with `activity_type = 'rate'`, including the caller's `userid`, the\
+ \ `videoid`, and an `activity_timestamp` (timeuuid). This row is written for\
+ \ both new ratings and updates to an existing rating."
operationId: post_rating_video_api_v1_videos__video_id_path__ratings_post
security:
- OAuth2PasswordBearer: []
@@ -1271,6 +1282,66 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/users/{user_id_path}/activity:
+ get:
+ tags:
+ - User Activity
+ summary: Get user activity timeline
+ description: Return a paginated timeline of a user's activity over the last
+ 30 days.
+ operationId: get_user_activity_api_v1_users__user_id_path__activity_get
+ parameters:
+ - name: user_id_path
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ title: User Id Path
+ - name: activity_type
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ description: Filter by activity type (view, comment, rate)
+ title: Activity Type
+ description: Filter by activity type (view, comment, rate)
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ description: Page number
+ default: 1
+ title: Page
+ description: Page number
+ - name: pageSize
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 100
+ minimum: 1
+ description: Items per page
+ default: 10
+ title: Pagesize
+ description: Items per page
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PaginatedResponse_UserActivityResponse_'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
/:
get:
summary: Health check
@@ -1579,6 +1650,20 @@ components:
- data
- pagination
title: PaginatedResponse[FlagResponse]
+ PaginatedResponse_UserActivityResponse_:
+ properties:
+ data:
+ items:
+ $ref: '#/components/schemas/UserActivityResponse'
+ type: array
+ title: Data
+ pagination:
+ $ref: '#/components/schemas/Pagination'
+ type: object
+ required:
+ - data
+ - pagination
+ title: PaginatedResponse[UserActivityResponse]
PaginatedResponse_VideoSummary_:
properties:
data:
@@ -1743,6 +1828,40 @@ components:
- email
- userId
title: User
+ UserActivityResponse:
+ properties:
+ userid:
+ type: string
+ format: uuid
+ title: Userid
+ activity_type:
+ type: string
+ enum:
+ - view
+ - comment
+ - rate
+ title: Activity Type
+ activity_id:
+ type: string
+ format: uuid
+ title: Activity Id
+ activity_timestamp:
+ type: string
+ format: date-time
+ title: Activity Timestamp
+ videoid:
+ type: string
+ format: uuid
+ title: Videoid
+ type: object
+ required:
+ - userid
+ - activity_type
+ - activity_id
+ - activity_timestamp
+ - videoid
+ title: UserActivityResponse
+ description: API response representation for a single user activity item.
UserCreateRequest:
properties:
firstName:
@@ -1873,7 +1992,7 @@ components:
description:
anyOf:
- type: string
- maxLength: 1000
+ maxLength: 2000
- type: 'null'
title: Description
tags:
@@ -2127,7 +2246,7 @@ components:
description:
anyOf:
- type: string
- maxLength: 1000
+ maxLength: 2000
- type: 'null'
title: Description
tags:
diff --git a/eslint.config.js b/eslint.config.js
index e67846f..1077168 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
- { ignores: ["dist"] },
+ { ignores: ["dist", ".claude"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
@@ -25,5 +25,11 @@ export default tseslint.config(
],
"@typescript-eslint/no-unused-vars": "off",
},
+ },
+ {
+ files: ["src/components/ui/**/*.{ts,tsx}"],
+ rules: {
+ "react-refresh/only-export-components": "off",
+ },
}
);
diff --git a/src/components/comments/CommentsSection.tsx b/src/components/comments/CommentsSection.tsx
index 7f07472..149573c 100644
--- a/src/components/comments/CommentsSection.tsx
+++ b/src/components/comments/CommentsSection.tsx
@@ -34,6 +34,7 @@ const CommentsSection = ({ videoId }: CommentsSectionProps) => {
// Append new comments when page data arrives
useEffect(() => {
if (commentPage) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setComments((prev) =>
page === 1 ? (commentPage.data as Comment[]) : [...prev, ...(commentPage.data as Comment[])]
);
diff --git a/src/components/educational/ExplainerModal.tsx b/src/components/educational/ExplainerModal.tsx
index 58ed5ea..19b1ee7 100644
--- a/src/components/educational/ExplainerModal.tsx
+++ b/src/components/educational/ExplainerModal.tsx
@@ -34,6 +34,7 @@ export const ExplainerModal = ({
const abortController = new AbortController();
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setIsLoading(true);
setError(null);
diff --git a/src/components/educational/WelcomeModal.tsx b/src/components/educational/WelcomeModal.tsx
index 6d19759..7073488 100644
--- a/src/components/educational/WelcomeModal.tsx
+++ b/src/components/educational/WelcomeModal.tsx
@@ -24,6 +24,7 @@ export const WelcomeModal = () => {
// Show modal if not welcomed and tour is not already enabled
if (!hasBeenWelcomed && !guidedTourEnabled) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setOpen(true);
}
}, [guidedTourEnabled]);
diff --git a/src/components/profile/ActivityTimeline.tsx b/src/components/profile/ActivityTimeline.tsx
new file mode 100644
index 0000000..4db340e
--- /dev/null
+++ b/src/components/profile/ActivityTimeline.tsx
@@ -0,0 +1,246 @@
+import { useState } from 'react';
+import { Link } from 'react-router-dom';
+import { useUserActivity } from '@/hooks/useApi';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Eye, MessageSquare, Star, Activity, ChevronLeft, ChevronRight } from 'lucide-react';
+import { UserActivity } from '@/types/api';
+import { PAGINATION } from '@/lib/constants';
+
+const PAGE_SIZE = PAGINATION.DEFAULT_PAGE_SIZE;
+
+type ActivityFilter = 'all' | 'view' | 'comment' | 'rate';
+
+function formatRelativeTime(timestamp: string): string {
+ const now = Date.now();
+ const then = new Date(timestamp).getTime();
+ const diffSeconds = Math.floor((now - then) / 1000);
+
+ if (diffSeconds < 60) return 'just now';
+ const diffMinutes = Math.floor(diffSeconds / 60);
+ if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
+ const diffHours = Math.floor(diffMinutes / 60);
+ if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
+ const diffDays = Math.floor(diffHours / 24);
+ if (diffDays === 1) return 'yesterday';
+ if (diffDays < 30) return `${diffDays} days ago`;
+ const diffMonths = Math.floor(diffDays / 30);
+ if (diffMonths < 12) return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
+ const diffYears = Math.floor(diffMonths / 12);
+ return `${diffYears} year${diffYears === 1 ? '' : 's'} ago`;
+}
+
+function activityIcon(type: UserActivity['activity_type']) {
+ switch (type) {
+ case 'view':
+ return
+ {formatRelativeTime(activity.activity_timestamp)} +
+Failed to load activity. Please try again later.
+No activity found.
++ Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, pagination.totalItems)} of {pagination.totalItems} activities +
+ )} +