diff --git a/lang/de.slf b/lang/de.slf index 41b41b50d8079b7e297ecfbbc4a46a5b4cbeef27..f0c49fdb208192816aad7fa465903acfbec8c5c2 100644 --- a/lang/de.slf +++ b/lang/de.slf @@ -12,6 +12,11 @@ enum.automatic_media_process_scheduler_state.enabled = "Eingeschaltet" enum.automatic_media_process_scheduler_state.disabled = "Ausgeschaltet" enum.automatic_media_process_scheduler_state.inherit = "Erben" +enum.client_stat_type.device_family = "Gerätefamilie" +enum.client_stat_type.device_brand = "Gerätemarke" +enum.client_stat_type.operating_system = "Betriebssystem" +enum.client_stat_type.browser = "Browser" + object.announcement = "Ankündigung" object.announcement.type = "Typ" object.announcement.page_visibility = "Seitensichtbarkeit" diff --git a/lang/en.slf b/lang/en.slf index ba00f9c062bf2ca206aacfa1dbe4b7f47c09bfb5..d0ddea3e7e74ddf222e95b23d4a99bf8be58f533 100644 --- a/lang/en.slf +++ b/lang/en.slf @@ -11,6 +11,11 @@ enum.automatic_media_process_scheduler_state.enabled = "Enabled" enum.automatic_media_process_scheduler_state.disabled = "Disabled" enum.automatic_media_process_scheduler_state.inherit = "Inherit" +enum.client_stat_type.device_family = "Device Family" +enum.client_stat_type.device_brand = "Device Brand" +enum.client_stat_type.operating_system = "Operating System" +enum.client_stat_type.browser = "Browser" + object.announcement = "Announcement" object.announcement.type = "Type" object.announcement.page_visibility = "Page Visibility" diff --git a/package-lock.json b/package-lock.json index 2a4c2277e35810b23b2bdc8f2aa2580c581b1d81..8738ac98d63fafb64e346ac204a03ffe12ee745a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "luxon": "^3.6.1", "next": "^15.3.0", "react": "^19.1.0", + "react-apexcharts": "^1.7.0", "react-bootstrap": "^2.10.9", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -1017,6 +1018,67 @@ "video.js": ">=6.0.0" } }, + "node_modules/@svgdotjs/svg.draggable.js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.6.tgz", + "integrity": "sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4" + } + }, + "node_modules/@svgdotjs/svg.filter.js": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.9.tgz", + "integrity": "sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@svgdotjs/svg.js": "^3.2.4" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@svgdotjs/svg.js": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.4.tgz", + "integrity": "sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Fuzzyma" + } + }, + "node_modules/@svgdotjs/svg.resize.js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.resize.js/-/svg.resize.js-2.0.5.tgz", + "integrity": "sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14.18" + }, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.select.js": "^4.0.1" + } + }, + "node_modules/@svgdotjs/svg.select.js": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz", + "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14.18" + }, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1449,6 +1511,13 @@ "node": ">=10.0.0" } }, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", + "license": "MIT", + "peer": true + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -1550,6 +1619,21 @@ "node": ">= 8" } }, + "node_modules/apexcharts": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz", + "integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@svgdotjs/svg.draggable.js": "^3.0.4", + "@svgdotjs/svg.filter.js": "^3.0.8", + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.resize.js": "^2.0.2", + "@svgdotjs/svg.select.js": "^4.0.1", + "@yr/monotone-cubic-spline": "^1.0.3" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -6323,6 +6407,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-apexcharts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-1.7.0.tgz", + "integrity": "sha512-03oScKJyNLRf0Oe+ihJxFZliBQM9vW3UWwomVn4YVRTN1jsIR58dLWt0v1sb8RwJVHDMbeHiKQueM0KGpn7nOA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "apexcharts": ">=4.0.0", + "react": ">=0.13" + } + }, "node_modules/react-bootstrap": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz", diff --git a/package.json b/package.json index 2ebe6c543337dfb00787e51b58d29f902406ee85..9229dfbca9522bf1790026ed25153b8bb4455771 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "remark-gfm": "^4.0.1", "sass": "1.77.6", "video.js": "^8.22.0", - "videojs-hotkeys": "^0.2.30" + "videojs-hotkeys": "^0.2.30", + "react-apexcharts": "^1.7.0" }, "devDependencies": { "@next/bundle-analyzer": "15.3.0", diff --git a/src/videoag/api/Backend.tsx b/src/videoag/api/Backend.tsx index 30bb55976a95e074736b6f5104fb6156a4c2d43f..a07c1ccb83c8c56f1ded852c36fa2933237670e1 100644 --- a/src/videoag/api/Backend.tsx +++ b/src/videoag/api/Backend.tsx @@ -41,6 +41,9 @@ import type { UpdateMySettingsRequest, GetLectureResponse, LogWatchedPublishMediumRequest, + GetPublishMediumStatsResponse, + GetLectureStatsResponse, + GetCourseStatsResponse, } from "./types"; /* global RequestInit */ @@ -523,6 +526,27 @@ export class BackendImpl { ); } + // GET /stats/publish_medium/{publish_medium_id} + getPublishMediumStats(publishMediumId: int): Promise<GetPublishMediumStatsResponse> { + return this.processResponse<GetPublishMediumStatsResponse>( + this.fetch(`${this.baseUrl()}/stats/publish_medium/${publishMediumId}`), + ); + } + + // GET /stats/lecture/{lecture_id} + getLectureStats(lectureId: int): Promise<GetLectureStatsResponse> { + return this.processResponse<GetLectureStatsResponse>( + this.fetch(`${this.baseUrl()}/stats/lecture/${lectureId}`), + ); + } + + // GET /stats/course/{course_id} + getCourseStats(courseId: int): Promise<GetCourseStatsResponse> { + return this.processResponse<GetCourseStatsResponse>( + this.fetch(`${this.baseUrl()}/stats/course/${courseId}`), + ); + } + // Special method for language files getLanguageFile(lang: string): Promise<Record<string, string>> { return this.fetch(`${this.staticUrl()}/lang/${lang}.json`) diff --git a/src/videoag/api/types.ts b/src/videoag/api/types.ts index 8862e113464d54c9a11196a542722e22566d63df..1822adfe91f542776128355c767cc9365be7d0a5 100644 --- a/src/videoag/api/types.ts +++ b/src/videoag/api/types.ts @@ -1,5 +1,6 @@ // Base types export type int = number; +export type float = number; export type long = number; export type handle = string; export type semester_string = string; @@ -614,3 +615,48 @@ export interface LogWatchedPublishMediumRequest { watch_start_time_sec: int; } // No Response Interface + +/// GET /stats/publish_medium/{publish_medium_id} +// No Request Interface +export interface GetPublishMediumStatsResponse { + client_stats: { + [key: string]: { + [key: string]: int; + }; + }; + segmented_stats: { + segment_duration_sec: int; + segment_total_view_counts: int[]; + segment_unique_view_counts: int[]; + }; + total_watched_seconds: int; + view_count: int; +} + +/// GET /stats/lecture/{lecture_id} +// No Request Interface +export interface GetLectureStatsResponse { + average_watch_speed: float; + daily_views: { + dates: string[]; + view_counts: int[]; + }; + total_watched_seconds: int; + view_count: int; +} + +/// GET /stats/course/{course_id} +// No Request Interface +export interface GetCourseStatsResponse { + average_watch_speed: float; + daily_views: { + dates: string[]; + view_counts: int[]; + }; + lecture_views: { + lecture_ids: int[]; + view_counts: int[]; + }; + total_watched_seconds: int; + view_count: int; +} diff --git a/src/videoag/course/CourseListing.tsx b/src/videoag/course/CourseListing.tsx index bfffb0b4479c50ef2603382c91ca7ec896c060bc..bd97300c64ac43f7bd40bd8dc99ac77392365f63 100644 --- a/src/videoag/course/CourseListing.tsx +++ b/src/videoag/course/CourseListing.tsx @@ -15,6 +15,7 @@ import { import { LectureListItem } from "./Lecture"; import DownloadAllModal from "./DownloadManager"; +import { CourseStats } from "./Stats"; function ListingHeader({ course }: { course: GetCourseResponse }) { const authStatus = useAuthStatus(); @@ -192,11 +193,15 @@ export default function CourseListing({ courseData: GetCourseResponse; disabled: boolean; }) { + const authStatus = useAuthStatus(); + const hasUserInfo = authStatus.hasUserInfo(); + return ( <> <Title title={courseData.full_name} /> <ListingHeader course={courseData} /> + {hasUserInfo && <CourseStats course={courseData} />} <ListingBody course={courseData} /> <UpdateOverlay show={disabled} /> </> diff --git a/src/videoag/course/Player.tsx b/src/videoag/course/Player.tsx index 0fb1c9279c3f441d50fd60799c4ac3e92f62056c..9e15197e2bb55745182c7392ece5acb7861f4bda 100644 --- a/src/videoag/course/Player.tsx +++ b/src/videoag/course/Player.tsx @@ -12,7 +12,7 @@ import VideoJsMarkers from "#src/videojs-markers"; import type { course, chapter, publish_medium, lecture } from "@/api/types"; import { useApi } from "@/api"; -import { ViewPermissionAuthorization } from "@/authentication"; +import { ViewPermissionAuthorization, useAuthStatus } from "@/authentication"; import { useLanguage } from "@/localization"; import { Card, Title, UpdateOverlay, showInfoToast, parseApiDatetime } from "@/miscellaneous"; import { @@ -32,6 +32,7 @@ import { } from "./Medium"; import { Chapters, AddChapterButton } from "./Chapter"; import { StatTracker } from "./StatTracker"; +import { LectureStats } from "./Stats"; import { PlayerData } from "#src/pages/dynamic_player"; @@ -406,6 +407,8 @@ export default function Player({ disabled: boolean; }) { const { language } = useLanguage(); + const authStatus = useAuthStatus(); + const hasUserInfo = authStatus.hasUserInfo(); const { course, lecture } = playerData; @@ -466,6 +469,7 @@ export default function Player({ </Head> <Title title={`${course.short_name} - ${lecture.title}`} /> <UpdateOverlay show={disabled} /> + {hasUserInfo && <LectureStats lecture={lecture} />} <Card objectVisible={lecture.visible} header={ diff --git a/src/videoag/course/Stats.tsx b/src/videoag/course/Stats.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e1c0c4923b11f355b932ceccfd899c1f3003f99 --- /dev/null +++ b/src/videoag/course/Stats.tsx @@ -0,0 +1,390 @@ +import { Suspense, useEffect, useState } from "react"; +import React from "react"; + +import type { + lecture, + course, + int, + GetCourseStatsResponse, + GetLectureStatsResponse, + GetPublishMediumStatsResponse, +} from "@/api/types"; +import { useApi } from "@/api"; +import { useLanguage } from "@/localization"; +import { + Card, + useMutexCall, + useMutexProcessing, + Spinner, + useTheme, + timestampToString, + datetimeToString, + ReloadBoundary, + CollapsableCard, +} from "@/miscellaneous"; +import { ErrorComponent } from "@/error"; + +import { getNamedPublishMedia, getSortedPlayerPublishMedia } from "./Medium"; + +// Lazy since the charts try to use the 'window' and this causes errors in server-side rendering +const Chart = React.lazy(() => import("react-apexcharts")); + +export function GraphImp({ + id, + title, + type, + dataX, + dataY, + height, +}: { + id: string; + title: string; + type: "line" | "bar"; + dataX: any[]; + dataY: any[]; + height?: any; +}) { + const { theme } = useTheme(); + return ( + <Chart + options={{ + chart: { + id: id, + background: "#00000000", // transparent + zoom: { + allowMouseWheelZoom: false, + }, + }, + xaxis: { + categories: dataX, + title: { + text: title, + }, + }, + theme: { + mode: theme, + }, + }} + series={[ + { + name: title, + data: dataY, + color: "#337ab7", + }, + ]} + type={type} + height={height ?? "500em"} + /> + ); +} + +export function LectureStatsImpl({ lecture }: { lecture: lecture }) { + const api = useApi(); + const { language } = useLanguage(); + const { theme } = useTheme(); + + const [lectureStats, setLectureStats] = useState<GetLectureStatsResponse | undefined>( + undefined, + ); + const [selectedMediumId, setSelectedMediumId] = useState<int | undefined>(undefined); + const [mediumStats, setMediumStats] = useState<GetPublishMediumStatsResponse | undefined>( + undefined, + ); + const [error, setError] = useState<any>(undefined); + + const lectureId = lecture.id; + + const reloadData = useMutexCall(() => + api + .getLectureStats(lectureId) + .then((res) => { + setLectureStats(res); + setError(undefined); + }) + .catch((e) => { + console.log("Error while loading lecture stats", e); + setError(e); + }), + ); + + const loadMediumStats = useMutexProcessing<int, void>((mediumId) => + api + .getPublishMediumStats(mediumId) + .then((res) => { + setMediumStats(res); + setError(undefined); + }) + .catch((e) => { + console.log("Error while loading publish medium stats", e); + setError(e); + }), + ); + + const namedMedia = getNamedPublishMedia( + getSortedPlayerPublishMedia(lecture.publish_media ?? []), + ); + + useEffect(() => { + setMediumStats(undefined); + if (selectedMediumId !== undefined) { + loadMediumStats(selectedMediumId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedMediumId]); + + useEffect(() => { + setSelectedMediumId(namedMedia.length > 0 ? namedMedia[0].medium.id : undefined); + reloadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lectureId]); + + if (error !== undefined) { + return ( + <ReloadBoundary reloadFunc={reloadData}> + <ErrorComponent error={error} objectName="Lecture Statistics" showButtons={false} /> + </ReloadBoundary> + ); + } + + if (lectureStats === undefined) { + return <Spinner visible={true} />; + } + + return ( + <div> + <table className="table" style={{ maxWidth: "20em" }}> + <tbody> + <tr> + <td>View Count</td> + <td>{lectureStats.view_count}</td> + </tr> + <tr> + <td>Total Watch Time</td> + <td>{timestampToString(lectureStats.total_watched_seconds)}</td> + </tr> + <tr> + <td>Average Watch Speed</td> + <td>{Math.round(lectureStats.average_watch_speed * 100)}%</td> + </tr> + </tbody> + </table> + <GraphImp + id="lectureDailyViews" + title="Daily Views" + type="line" + dataX={lectureStats.daily_views.dates} + dataY={lectureStats.daily_views.view_counts} + /> + <hr /> + <Card + header={ + <> + Medium Statistics + <select + className="form-select w-auto ms-2" + value={selectedMediumId} + onChange={(e) => setSelectedMediumId(parseInt(e.target.value))} + > + {namedMedia.map(({ name, medium }) => ( + <option key={medium.id} value={medium.id}> + {name} + </option> + ))} + </select> + </> + } + > + {mediumStats && ( + <> + <Chart + options={{ + chart: { + id: "mediumViewTimeline", + background: "#00000000", // transparent + zoom: { + allowMouseWheelZoom: false, + }, + }, + xaxis: { + type: "numeric", + tickAmount: 10, + title: { + text: "Medium Timeline", + }, + labels: { + formatter: (val: int) => timestampToString(val), + }, + }, + dataLabels: { + enabled: false, + }, + theme: { + mode: theme, + }, + stroke: { + curve: "smooth", + }, + }} + series={[ + { + name: "Unique Views", + data: mediumStats.segmented_stats.segment_unique_view_counts.map( + (count, index) => [ + index * + mediumStats.segmented_stats.segment_duration_sec, + count, + ], + ), + color: "#337ab7", + }, + { + name: "Total Views", + data: mediumStats.segmented_stats.segment_total_view_counts.map( + (count, index) => [ + index * + mediumStats.segmented_stats.segment_duration_sec, + count, + ], + ), + color: "#ffffff", + }, + ]} + type="area" + height="500em" + /> + <div className="row"> + {Object.entries(mediumStats.client_stats).map( + ([typeName, clientStats]) => { + const valuesWithCounts = Object.entries(clientStats).sort( + ([value, count]) => count, + ); + return ( + <div + key={typeName} + className="col-12 col-sm-6 col-md-4 col-lg-3" + > + <GraphImp + id={"clientStats" + typeName} + title={language.get( + `enum.client_stat_type.${typeName}`, + )} + type="bar" + dataX={valuesWithCounts.map( + ([value, count]) => value, + )} + dataY={valuesWithCounts.map( + ([value, count]) => count, + )} + height={"250em"} + /> + </div> + ); + }, + )} + </div> + </> + )} + </Card> + </div> + ); +} + +export function LectureStats({ lecture }: { lecture: lecture }) { + return ( + <CollapsableCard header="Statistics" defaultVisible={false}> + <LectureStatsImpl lecture={lecture} /> + </CollapsableCard> + ); +} + +export function CourseStatsImpl({ course }: { course: course }) { + const api = useApi(); + const [courseStats, setCourseStats] = useState<GetCourseStatsResponse | undefined>(undefined); + const [error, setError] = useState<any>(undefined); + + const courseId = course.id; + + const reloadData = useMutexCall(() => + api + .getCourseStats(courseId) + .then((res) => { + setCourseStats(res); + setError(undefined); + }) + .catch((e) => { + console.log("Error while loading course stats", e); + setError(e); + }), + ); + + useEffect(() => { + reloadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [courseId]); + + let comp = null; + if (error !== undefined) { + comp = <ErrorComponent error={error} objectName="Course Statistics" showButtons={false} />; + } else if (courseStats === undefined) { + comp = <Spinner visible={true} />; + } else { + comp = ( + <> + <table className="table" style={{ maxWidth: "20em" }}> + <tbody> + <tr> + <td>View Count</td> + <td>{courseStats.view_count}</td> + </tr> + <tr> + <td>Total Watch Time</td> + <td>{timestampToString(courseStats.total_watched_seconds)}</td> + </tr> + <tr> + <td>Average Watch Speed</td> + <td>{Math.round(courseStats.average_watch_speed * 100)}%</td> + </tr> + </tbody> + </table> + <div className="col-12 col-lg-6 d-inline-block"> + <GraphImp + id="courseDailyViews" + title="Daily Views" + type="line" + dataX={courseStats.daily_views.dates} + dataY={courseStats.daily_views.view_counts} + /> + </div> + <div className="col-12 col-lg-6 d-inline-block"> + <GraphImp + id="lectureViewCounts" + title="Lecture Views" + type="bar" + dataX={courseStats.lecture_views.lecture_ids.map( + /* Not the most efficient way... */ + (lecId: int) => + datetimeToString( + course.lectures!.find((lec) => lec.id == lecId)!.time, + ), + )} + dataY={courseStats.lecture_views.view_counts} + /> + </div> + </> + ); + } + + return ( + <ReloadBoundary reloadFunc={reloadData}> + <Suspense fallback={<Spinner visible={true} />}>{comp}</Suspense> + </ReloadBoundary> + ); +} + +export function CourseStats({ course }: { course: course }) { + return ( + <CollapsableCard header="Statistics" defaultVisible={false}> + <CourseStatsImpl course={course} /> + </CollapsableCard> + ); +}