diff --git a/.github/workflows/feat-test-release.yml b/.github/workflows/feat-test-release.yml index 31f9817a..439ba910 100644 --- a/.github/workflows/feat-test-release.yml +++ b/.github/workflows/feat-test-release.yml @@ -124,3 +124,56 @@ jobs: files: | ${{ steps.prepare_apk.outputs.apk_path }} token: ${{ secrets.GITHUB_TOKEN }} + + build-server-docker: + name: Build & Push Server Docker (feat-test) + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set lowercase image name + id: image_name + run: | + REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + NAME_LOWER=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]') + OWNER_LOWER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + DOCKERHUB_USER_LOWER=$(echo "${{ secrets.DOCKERHUB_USERNAME }}" | tr '[:upper:]' '[:lower:]') + echo "ghcr=${REGISTRY}/${REPO_LOWER}" >> $GITHUB_OUTPUT + echo "dockerhub=${DOCKERHUB_USER_LOWER}/${NAME_LOWER}" >> $GITHUB_OUTPUT + env: + REGISTRY: ghcr.io + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + target: runner + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ steps.image_name.outputs.ghcr }}:feat-test + ${{ steps.image_name.outputs.dockerhub }}:feat-test diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4230a7fc..c05b09e0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -40,6 +40,7 @@ import BookDetail from "./pages/BookDetail"; import AuthorDetail from "./pages/AuthorDetail"; import Settings from "./pages/Settings"; import Notes from "./pages/Notes"; +import Stats from "./pages/Stats"; // Stores import { useAuthStore, useThemeStore } from "./stores/authStore"; @@ -533,6 +534,14 @@ function AppRoutes() { } /> + + + + } + /> (0); + const accumulatedRef = useRef(0); + const isActiveRef = useRef(false); + const bookIdRef = useRef(bookId); + const intervalRef = useRef | null>(null); + + // Update bookId ref when it changes + useEffect(() => { + bookIdRef.current = bookId; + }, [bookId]); + + const reportSession = useCallback(async (durationSecs: number) => { + const currentBookId = bookIdRef.current; + if (!currentBookId || durationSecs < MIN_REPORT_THRESHOLD) return; + + try { + const hour = new Date().getHours(); + await getApiClient().recordReadingSession(currentBookId, durationSecs, hour); + } catch (err) { + console.warn('Failed to report reading session:', err); + } + }, []); + + const startTimer = useCallback(() => { + if (!isActiveRef.current) { + isActiveRef.current = true; + startTimeRef.current = Date.now(); + } + }, []); + + const pauseTimer = useCallback(() => { + if (isActiveRef.current && startTimeRef.current > 0) { + const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000); + accumulatedRef.current += elapsed; + isActiveRef.current = false; + startTimeRef.current = 0; + } + }, []); + + const flushTimer = useCallback(async () => { + pauseTimer(); + const total = accumulatedRef.current; + if (total >= MIN_REPORT_THRESHOLD) { + await reportSession(total); + } + accumulatedRef.current = 0; + }, [pauseTimer, reportSession]); + + // Periodic report while reading + const startPeriodicReport = useCallback(() => { + if (intervalRef.current) return; + intervalRef.current = setInterval(() => { + if (isActiveRef.current && startTimeRef.current > 0) { + const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000); + accumulatedRef.current += elapsed; + startTimeRef.current = Date.now(); + + // Report accumulated time every interval + const total = accumulatedRef.current; + accumulatedRef.current = 0; + if (total >= MIN_REPORT_THRESHOLD) { + reportSession(total); + } + } + }, REPORT_INTERVAL * 1000); + }, [reportSession]); + + const stopPeriodicReport = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + // Handle page visibility changes + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden) { + pauseTimer(); + stopPeriodicReport(); + } else { + startTimer(); + startPeriodicReport(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [startTimer, pauseTimer, startPeriodicReport, stopPeriodicReport]); + + // Handle beforeunload + useEffect(() => { + const handleBeforeUnload = () => { + stopPeriodicReport(); + flushTimer(); + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [flushTimer, stopPeriodicReport]); + + // Start timer when bookId is set + useEffect(() => { + if (bookId) { + startTimer(); + startPeriodicReport(); + } + return () => { + stopPeriodicReport(); + flushTimer(); + }; + }, [bookId, startTimer, flushTimer, startPeriodicReport, stopPeriodicReport]); + + return { startTimer, pauseTimer, flushTimer }; +} diff --git a/apps/desktop/src/pages/Library.tsx b/apps/desktop/src/pages/Library.tsx index a92f2cbf..336b269b 100644 --- a/apps/desktop/src/pages/Library.tsx +++ b/apps/desktop/src/pages/Library.tsx @@ -12,6 +12,7 @@ import { List, PenLine, Search, + ArrowLeft, } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -489,6 +490,12 @@ export default function Library() {
{/* Header */}
+ {/* Mobile Header */} +
+

我的书库

+
+ + {/* Desktop Header */}

我的书库 diff --git a/apps/desktop/src/pages/Profile.tsx b/apps/desktop/src/pages/Profile.tsx index 12507825..59cdffe4 100644 --- a/apps/desktop/src/pages/Profile.tsx +++ b/apps/desktop/src/pages/Profile.tsx @@ -22,6 +22,7 @@ import { RefreshCw, Shield, LogOut, + BarChart3, } from "lucide-react"; function formatDate(dateStr: string): string { @@ -58,7 +59,7 @@ type TabKey = "collections" | "reading" | "favorites" | "downloads" | "notes"; export default function Profile() { const navigate = useNavigate(); - const { user, logout } = useAuthStore(); + const { user, logout, isVip, plusUser } = useAuthStore(); const [activeTab, setActiveTab] = useState("collections"); const [collections, setCollections] = useState([]); const [favorites, setFavorites] = useState([]); @@ -75,6 +76,29 @@ export default function Profile() { const [showDropdown, setShowDropdown] = useState(false); const [syncing, setSyncing] = useState(null); const dropdownRef = useRef(null); + const [readingSummary, setReadingSummary] = useState<{ + todaySecs: number; + weekSecs: number; + monthSecs: number; + yearSecs: number; + totalSecs: number; + } | null>(null); + + // Fetch reading time summary + useEffect(() => { + const fetchSummary = async () => { + try { + const api = getApiClient(); + const res = await api.getReadingTimeSummary(); + if (res.success && res.data) { + setReadingSummary(res.data); + } + } catch (err) { + console.error("Failed to fetch reading summary:", err); + } + }; + fetchSummary(); + }, []); // Close dropdown on click outside useEffect(() => { @@ -581,11 +605,84 @@ export default function Profile() {

{user?.username || "用户"}

-

{user?.role === "admin" ? "管理员" : "普通用户"}

+

+ {(() => { + if (user?.role === "admin") return "管理员"; + if (!isVip) return "普通用户"; + const level = plusUser?.level; + if (level === "lifetime") return "永久会员"; + if (level === "year") return "年会员"; + return "会员"; + })()} +

+ {/* Reading Time Card */} + {isVip ? ( + readingSummary && ( + +
+
+
+ +
+
+

阅读时长

+

+ {(() => { + const format = (secs: number) => { + if (secs < 60) return `${secs}秒`; + if (secs < 3600) return `${Math.floor(secs / 60)}分钟`; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return m > 0 ? `${h}小时${m}分钟` : `${h}小时`; + }; + if (readingSummary.todaySecs > 0) { + return `今日阅读 ${format(readingSummary.todaySecs)}`; + } + if (readingSummary.weekSecs > 0) { + return `本周阅读 ${format(readingSummary.weekSecs)}`; + } + if (readingSummary.monthSecs > 0) { + return `本月阅读 ${format(readingSummary.monthSecs)}`; + } + if (readingSummary.yearSecs > 0) { + return `今年阅读 ${format(readingSummary.yearSecs)}`; + } + return "今日还没有阅读"; + })()} +

+
+
+ +
+ + ) + ) : ( + +
+
+
+ +
+
+

阅读时长

+

开通会员解锁阅读时长统计

+
+
+ +
+ + )} + {/* Tabs */}
{tabs.map((tab) => { diff --git a/apps/desktop/src/pages/Reader.tsx b/apps/desktop/src/pages/Reader.tsx index 576350f6..1646fddc 100644 --- a/apps/desktop/src/pages/Reader.tsx +++ b/apps/desktop/src/pages/Reader.tsx @@ -9,6 +9,7 @@ import { ArrowLeft, Settings, BookOpen, Bookmark, ChevronLeft, ChevronRight, Vol import * as pdfjsLib from 'pdfjs-dist'; import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; import { getCachedChapters, setCachedChapters, getCachedChapterContent, setCachedChapterContent, getCachedFile, setCachedFile } from '../utils/bookCache'; +import { useReadingTimer } from '../hooks/useReadingTimer'; // 设置 PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker; @@ -535,6 +536,11 @@ interface ReaderControlsProps { onNavigateTts: () => void; scrollContainerRef?: React.RefObject; isPdf?: boolean; + hidden?: boolean; + onMouseEnterTop?: () => void; + onMouseLeaveTop?: () => void; + onMouseEnterBottom?: () => void; + onMouseLeaveBottom?: () => void; } function ReaderControls({ @@ -556,110 +562,136 @@ function ReaderControls({ showSettings, onNavigateTts, isPdf = false, + hidden = false, + onMouseEnterTop, + onMouseLeaveTop, + onMouseEnterBottom, + onMouseLeaveBottom, }: ReaderControlsProps) { const progress = totalChapters > 0 ? Math.round(((currentChapter + 1) / totalChapters) * 100) : 0; + const barTransition = 'transform 0.3s ease-in-out'; return ( <> {/* Top bar */} -
- -
-

- {book?.title || '阅读中'} -

-

- {isPdf ? `${book?.title || ''} · 第 ${currentChapter + 1} / ${totalChapters} 页` : chapterTitle} -

-
-
- +
+ +
+

+ {book?.title || '阅读中'} +

+

+ {isPdf ? `${book?.title || ''} · 第 ${currentChapter + 1} / ${totalChapters} 页` : chapterTitle} +

+
+
+ +
+
{/* Bottom bar */} -
- {/* Progress bar */} -
- onGoToPage(Number(e.target.value))} - className="w-full h-1 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer" - /> -
- {progress}% - {currentChapter + 1} / {totalChapters} - 100% +
+ - {/* Controls */} -
-
- - -
+ {/* Controls */} +
+
+ + +
-
- - -
+
+ + +
-
- - +
+ + +
@@ -691,6 +723,10 @@ export default function Reader() { const [bookmarks, setBookmarks] = useState([]); const [notes, setNotes] = useState([]); const [highlights, setHighlights] = useState([]); + const [hidden, setHidden] = useState(false); + + const lastScrollY = useRef(0); + const hideTimer = useRef(null); const [selectedText, setSelectedText] = useState(''); const [selectionMenuPos, setSelectionMenuPos] = useState({ x: 0, y: 0 }); @@ -709,6 +745,9 @@ export default function Reader() { const [pdfOutline, setPdfOutline] = useState>([]); const isPdf = book ? (book.fileType || book.format) === 'pdf' : false; + // ── Reading Timer ─────────────────────────────────────────────────────── + const { flushTimer } = useReadingTimer(id); + // ── Note & Highlight state ──────────────────────────────────────────── const [showNoteModalState, setShowNoteModalState] = useState(false); const [noteSelectedTextState, setNoteSelectedTextState] = useState(''); @@ -967,6 +1006,70 @@ export default function Reader() { } }, [currentChapter, resetScroll]); + // ── Auto hide toolbar on scroll ────────────────────────────────────── + const scrollHandlerRef = useRef<(() => void) | null>(null); + + useEffect(() => { + if (isPdf) return; + if (scrollHandlerRef.current) return; + + const handleScroll = () => { + const container = contentRef.current; + if (!container) return; + const currentY = container.scrollTop; + if (currentY > lastScrollY.current && currentY > 60) { + setHidden(true); + } else if (currentY < lastScrollY.current) { + setHidden(false); + } + lastScrollY.current = currentY; + }; + + scrollHandlerRef.current = handleScroll; + + // Try to bind to content container + const tryBind = () => { + const container = contentRef.current; + if (container) { + container.addEventListener('scroll', handleScroll, { passive: true }); + return true; + } + return false; + }; + + // Try immediately and retry after a short delay if not ready + if (!tryBind()) { + setTimeout(tryBind, 100); + setTimeout(tryBind, 500); + } + + return () => { + const container = contentRef.current; + if (container && scrollHandlerRef.current) { + container.removeEventListener('scroll', scrollHandlerRef.current); + } + scrollHandlerRef.current = null; + }; + }, [isPdf, chapterContent]); + + const handleMouseEnterTop = useCallback(() => { + if (hideTimer.current) clearTimeout(hideTimer.current); + setHidden(false); + }, []); + + const handleMouseLeaveTop = useCallback(() => { + hideTimer.current = setTimeout(() => setHidden(true), 2000); + }, []); + + const handleMouseEnterBottom = useCallback(() => { + if (hideTimer.current) clearTimeout(hideTimer.current); + setHidden(false); + }, []); + + const handleMouseLeaveBottom = useCallback(() => { + hideTimer.current = setTimeout(() => setHidden(true), 2000); + }, []); + // ── Keyboard shortcuts ─────────────────────────────────────────────── useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1104,8 +1207,9 @@ export default function Reader() { const handleGoBack = useCallback(() => { saveReadingPosition(getCurrentScrollTop(), 'exit'); + flushTimer(); navigate(-1); - }, [navigate, saveReadingPosition, getCurrentScrollTop]); + }, [navigate, saveReadingPosition, getCurrentScrollTop, flushTimer]); const handlePdfLoaded = useCallback(({ totalPages, outline }: { totalPages: number; outline: Array<{ title: string; page: number }> }) => { setPdfTotalPages(totalPages); @@ -1301,6 +1405,11 @@ export default function Reader() { onNavigateTts={() => navigate(`/reader/${id}/tts`)} scrollContainerRef={contentRef} isPdf={isPdf} + hidden={hidden} + onMouseEnterTop={handleMouseEnterTop} + onMouseLeaveTop={handleMouseLeaveTop} + onMouseEnterBottom={handleMouseEnterBottom} + onMouseLeaveBottom={handleMouseLeaveBottom} /> {/* TOC Panel */} diff --git a/apps/desktop/src/pages/Recommend.tsx b/apps/desktop/src/pages/Recommend.tsx index 052f2076..d85fd502 100644 --- a/apps/desktop/src/pages/Recommend.tsx +++ b/apps/desktop/src/pages/Recommend.tsx @@ -1,6 +1,6 @@ // @ts-nocheck import { Book } from "@bookdock/api-client"; -import { BookOpen, Clock, RefreshCw, Sparkles, ThumbsUp } from "lucide-react"; +import { BookOpen, Clock, RefreshCw, Sparkles, ThumbsUp, ArrowLeft } from "lucide-react"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { @@ -112,7 +112,12 @@ export default function Recommend() { return (
- {/* Header */} + {/* Mobile Header */} +
+

推荐

+
+ + {/* Desktop Header */}

diff --git a/apps/desktop/src/pages/Settings.tsx b/apps/desktop/src/pages/Settings.tsx index 17638197..f1427aba 100644 --- a/apps/desktop/src/pages/Settings.tsx +++ b/apps/desktop/src/pages/Settings.tsx @@ -15,6 +15,7 @@ import { CardTitle, } from "@bookdock/ui"; import { + ArrowLeft, BookOpen, Hand, HardDrive, @@ -29,6 +30,7 @@ import { Volume2, } from "lucide-react"; import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useAuthStore, useThemeStore } from "../stores/authStore"; import { useReaderStore as useReaderStore2 } from "../stores/themeStore"; @@ -470,8 +472,22 @@ export default function Settings() { alert("会员升级功能即将上线,敬请期待!"); }; + const navigate = useNavigate(); + return (
+ {/* Mobile Header */} +
+ +

设置

+
+ + {/* Desktop Title */}

设置

{/* Profile Section */} diff --git a/apps/desktop/src/pages/Stats.tsx b/apps/desktop/src/pages/Stats.tsx new file mode 100644 index 00000000..08acebc2 --- /dev/null +++ b/apps/desktop/src/pages/Stats.tsx @@ -0,0 +1,267 @@ +import { useState, useEffect, useMemo } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { getApiClient, type PeriodReadingStats, type DailyHourStats } from "@bookdock/api-client"; +import { useAuthStore } from "../stores/authStore"; +import { ArrowLeft, Clock, BookOpen, BarChart3 } from "lucide-react"; + +type Period = "day" | "week" | "month" | "year"; + +const PERIOD_LABELS: Record = { + day: "日", + week: "周", + month: "月", + year: "年", +}; + +function formatDuration(secs: number): string { + if (secs < 60) return `${secs}秒`; + if (secs < 3600) return `${Math.floor(secs / 60)}分钟`; + const hours = Math.floor(secs / 3600); + const mins = Math.floor((secs % 3600) / 60); + if (mins === 0) return `${hours}小时`; + return `${hours}小时${mins}分钟`; +} + +function BarChart({ data, maxValue, labelKey, valueKey }: { data: any[]; maxValue: number; labelKey: string; valueKey: string }) { + if (maxValue === 0) { + return ( +
+ 暂无阅读数据 +
+ ); + } + + return ( +
+ {data.map((item, i) => { + const value = item[valueKey] || 0; + const height = maxValue > 0 ? (value / maxValue) * 100 : 0; + return ( +
+
+
+
+ {formatDuration(value)} +
+
+
+ + {item[labelKey]} + +
+ ); + })} +
+ ); +} + +export default function Stats() { + const navigate = useNavigate(); + const { user, isVip } = useAuthStore(); + const [period, setPeriod] = useState("week"); + const [stats, setStats] = useState(null); + const [dailyHours, setDailyHours] = useState(null); + const [loading, setLoading] = useState(false); + const [totalTime, setTotalTime] = useState(0); + + // Redirect non-vip users to membership page + useEffect(() => { + if (!isVip) { + navigate("/membership"); + } + }, [isVip, navigate]); + + if (!isVip) { + return null; + } + + // Fetch summary + useEffect(() => { + const fetchSummary = async () => { + try { + const api = getApiClient(); + const res = await api.getReadingTimeSummary(); + if (res.success && res.data) { + setTotalTime(res.data.totalSecs); + } + } catch (err) { + console.error("Failed to fetch reading summary:", err); + } + }; + fetchSummary(); + }, []); + + // Fetch period stats + useEffect(() => { + const fetchStats = async () => { + setLoading(true); + try { + const api = getApiClient(); + const res = await api.getPeriodReadingStats(period); + if (res.success && res.data) { + setStats(res.data); + } + } catch (err) { + console.error("Failed to fetch period stats:", err); + } finally { + setLoading(false); + } + }; + fetchStats(); + }, [period]); + + // Fetch daily hours when period is day + useEffect(() => { + if (period !== "day") { + setDailyHours(null); + return; + } + const fetchDaily = async () => { + try { + const api = getApiClient(); + const res = await api.getDailyReadingHours(); + if (res.success && res.data) { + setDailyHours(res.data); + } + } catch (err) { + console.error("Failed to fetch daily hours:", err); + } + }; + fetchDaily(); + }, [period]); + + const chartData = useMemo(() => { + if (period === "day" && dailyHours) { + return dailyHours.hours.map((h) => ({ + label: `${h.hour}时`, + durationSecs: h.durationSecs, + })); + } + if (stats) { + return stats.breakdown; + } + return []; + }, [period, stats, dailyHours]); + + const maxValue = useMemo(() => { + return Math.max(...chartData.map((d) => d.durationSecs), 1); + }, [chartData]); + + return ( +
+
+ {/* Header */} +
+ +

阅读统计

+
+ + {/* Summary Cards */} +
+
+
+ + 累计阅读时长 +
+

+ {formatDuration(totalTime)} +

+
+
+
+ + 本周期阅读 +
+

+ {stats ? formatDuration(stats.totalDurationSecs) : "0分钟"} +

+
+
+ + {/* Period Tabs */} +
+
+ {(["day", "week", "month", "year"] as Period[]).map((p) => ( + + ))} +
+ +
+ {loading ? ( +
+
+
+ ) : ( + <> +
+

+ {period === "day" ? "24小时分布" : `${PERIOD_LABELS[period]}阅读分布`} +

+ + 共 {stats?.bookCount || 0} 本书 + +
+ {stats && stats.totalDurationSecs === 0 ? ( +
+ +

暂无阅读数据

+

{PERIOD_LABELS[period]}内还没有阅读记录

+
+ ) : ( + + )} + + )} +
+
+ + {/* Book List */} + {stats && stats.totalDurationSecs > 0 && ( +
+

+ 本周期阅读明细 +

+
+ {stats.breakdown + .filter((b) => b.durationSecs > 0) + .map((b, i) => ( +
+ {b.label} + + {formatDuration(b.durationSecs)} + +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/mobile/src/navigation/RootNavigator.tsx b/apps/mobile/src/navigation/RootNavigator.tsx index a121d891..b3cfd4fe 100644 --- a/apps/mobile/src/navigation/RootNavigator.tsx +++ b/apps/mobile/src/navigation/RootNavigator.tsx @@ -15,6 +15,7 @@ import { BookDetailScreen } from "../screens/BookDetailScreen"; import { AuthorDetailScreen } from "../screens/AuthorDetailScreen"; import { NotesScreen } from "../screens/NotesScreen"; import { SearchScreen } from "../screens/SearchScreen"; +import StatsScreen from "../screens/StatsScreen"; import { useAuthStore, useThemeStore } from "../stores"; import { getTheme } from "../utils/theme"; import { setNavigationBarAuto } from "../utils/navigationBar"; @@ -141,6 +142,11 @@ export function RootNavigator() { component={NotesScreen} options={{ headerShown: false }} /> + )} (null); const [showCreateModal, setShowCreateModal] = useState(false); const [newCollectionName, setNewCollectionName] = useState(''); + const [readingSummary, setReadingSummary] = useState<{ + todaySecs: number; + weekSecs: number; + monthSecs: number; + yearSecs: number; + totalSecs: number; + } | null>(null); + + // Fetch reading time summary + useEffect(() => { + const fetchSummary = async () => { + try { + const api = getApiClient(); + const res = await api.getReadingTimeSummary(); + if (res.success && res.data) { + setReadingSummary(res.data); + } + } catch (err) { + console.error('Failed to fetch reading summary:', err); + } + }; + fetchSummary(); + }, []); const { checkUpdate, @@ -350,6 +373,62 @@ export function ProfileScreen() { + {/* Reading Time Card */} + {isVip ? ( + readingSummary && ( + navigation.navigate('Stats')} + > + + + + 阅读时长 + + {(() => { + const format = (secs: number) => { + if (secs < 60) return `${secs}秒`; + if (secs < 3600) return `${Math.floor(secs / 60)}分钟`; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return m > 0 ? `${h}小时${m}分钟` : `${h}小时`; + }; + if (readingSummary.todaySecs > 0) { + return `今日阅读 ${format(readingSummary.todaySecs)}`; + } + if (readingSummary.weekSecs > 0) { + return `本周阅读 ${format(readingSummary.weekSecs)}`; + } + if (readingSummary.monthSecs > 0) { + return `本月阅读 ${format(readingSummary.monthSecs)}`; + } + if (readingSummary.yearSecs > 0) { + return `今年阅读 ${format(readingSummary.yearSecs)}`; + } + return '今日还没有阅读'; + })()} + + + + + + ) + ) : ( + navigation.navigate('MemberBenefits')} + > + + + + 阅读时长 + 开通会员解锁阅读时长统计 + + + + + )} + {/* Tabs */} {tabs.map((tab) => ( @@ -657,5 +736,32 @@ function createStyles(theme: ReturnType) { fontSize: fontSizes.xs, marginTop: spacing.xs, }, + readingTimeCard: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginHorizontal: spacing.md, + marginTop: spacing.md, + padding: spacing.lg, + borderRadius: borderRadius.lg, + }, + readingTimeContent: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + readingTimeTextContainer: { + gap: spacing.xs, + }, + readingTimeLabel: { + fontSize: fontSizes.sm, + color: '#fff', + opacity: 0.8, + }, + readingTimeValue: { + fontSize: fontSizes.md, + color: '#fff', + fontWeight: '600', + }, }); } diff --git a/apps/mobile/src/screens/ReaderScreen.tsx b/apps/mobile/src/screens/ReaderScreen.tsx index 1f848378..afe76d82 100644 --- a/apps/mobile/src/screens/ReaderScreen.tsx +++ b/apps/mobile/src/screens/ReaderScreen.tsx @@ -1188,6 +1188,99 @@ export function ReaderScreen() { showBarsRef.current = showBars; }, [showBars]); + // ── Reading Timer ───────────────────────────────────────────────────── + const readingStartTimeRef = useRef(0); + const accumulatedReadingTimeRef = useRef(0); + const isReadingActiveRef = useRef(false); + const readingIntervalRef = useRef | null>(null); + const REPORT_INTERVAL = 3; // Report every 3 seconds + + const startReadingTimer = useCallback(() => { + if (!isReadingActiveRef.current) { + isReadingActiveRef.current = true; + readingStartTimeRef.current = Date.now(); + } + }, []); + + const pauseReadingTimer = useCallback(() => { + if (isReadingActiveRef.current && readingStartTimeRef.current > 0) { + const elapsed = Math.floor((Date.now() - readingStartTimeRef.current) / 1000); + accumulatedReadingTimeRef.current += elapsed; + isReadingActiveRef.current = false; + readingStartTimeRef.current = 0; + } + }, []); + + const flushReadingTimer = useCallback(async () => { + pauseReadingTimer(); + const total = accumulatedReadingTimeRef.current; + if (total >= 10) { // Minimum 10 seconds to report + try { + const hour = new Date().getHours(); + await getApiClient().recordReadingSession(book.id, total, hour); + } catch (err) { + console.warn('Failed to report reading session:', err); + } + } + accumulatedReadingTimeRef.current = 0; + }, [pauseReadingTimer, book.id]); + + const startPeriodicReport = useCallback(() => { + if (readingIntervalRef.current) return; + readingIntervalRef.current = setInterval(() => { + if (isReadingActiveRef.current && readingStartTimeRef.current > 0) { + const elapsed = Math.floor((Date.now() - readingStartTimeRef.current) / 1000); + accumulatedReadingTimeRef.current += elapsed; + readingStartTimeRef.current = Date.now(); + + // Report accumulated time every interval + const total = accumulatedReadingTimeRef.current; + accumulatedReadingTimeRef.current = 0; + if (total >= 1) { + getApiClient().recordReadingSession(book.id, total, new Date().getHours()) + .catch((err) => console.warn('Failed to report reading session:', err)); + } + } + }, REPORT_INTERVAL * 1000); + }, [book.id]); + + const stopPeriodicReport = useCallback(() => { + if (readingIntervalRef.current) { + clearInterval(readingIntervalRef.current); + readingIntervalRef.current = null; + } + }, []); + + // Handle AppState changes for reading timer + useEffect(() => { + const handleAppStateChange = (nextAppState: string) => { + if (nextAppState === 'active') { + startReadingTimer(); + startPeriodicReport(); + } else { + pauseReadingTimer(); + stopPeriodicReport(); + } + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + return () => { + subscription.remove(); + }; + }, [startReadingTimer, pauseReadingTimer, startPeriodicReport, stopPeriodicReport]); + + // Start timer when screen is focused, flush when unfocused + useFocusEffect( + useCallback(() => { + startReadingTimer(); + startPeriodicReport(); + return () => { + stopPeriodicReport(); + flushReadingTimer(); + }; + }, [startReadingTimer, flushReadingTimer, startPeriodicReport, stopPeriodicReport]) + ); + // Stable WebView source object. The scroll-mode WebView re-renders // frequently as scroll progress updates; without this memo the inline // `source={{ html: htmlContent }}` object would be a new reference on diff --git a/apps/mobile/src/screens/StatsScreen.tsx b/apps/mobile/src/screens/StatsScreen.tsx new file mode 100644 index 00000000..b1b5c9cc --- /dev/null +++ b/apps/mobile/src/screens/StatsScreen.tsx @@ -0,0 +1,378 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { Ionicons } from '@expo/vector-icons'; +import { getApiClient, type PeriodReadingStats, type DailyHourStats } from '@bookdock/api-client'; +import { useThemeStore, useAuthStore } from '../stores'; +import { getTheme, spacing, fontSizes, borderRadius } from '../utils/theme'; +import type { RootStackParamList } from '../navigation/types'; + +type Period = 'day' | 'week' | 'month' | 'year'; + +const PERIOD_LABELS: Record = { + day: '日', + week: '周', + month: '月', + year: '年', +}; + +function formatDuration(secs: number): string { + if (secs < 60) return `${secs}秒`; + if (secs < 3600) return `${Math.floor(secs / 60)}分钟`; + const hours = Math.floor(secs / 3600); + const mins = Math.floor((secs % 3600) / 60); + if (mins === 0) return `${hours}小时`; + return `${hours}小时${mins}分钟`; +} + +export default function StatsScreen() { + const navigation = useNavigation>(); + const actualTheme = useThemeStore((state) => state.actualTheme); + const theme = getTheme(actualTheme === 'dark'); + const { isVip } = useAuthStore(); + + const [period, setPeriod] = useState('week'); + const [stats, setStats] = useState(null); + const [dailyHours, setDailyHours] = useState(null); + const [loading, setLoading] = useState(false); + const [totalTime, setTotalTime] = useState(0); + + const styles = useMemo(() => createStyles(theme), [theme]); + + // Redirect non-vip users + useEffect(() => { + if (!isVip) { + navigation.navigate('MemberBenefits'); + } + }, [isVip, navigation]); + + if (!isVip) { + return null; + } + + // Fetch summary + useEffect(() => { + const fetchSummary = async () => { + try { + const api = getApiClient(); + const res = await api.getReadingTimeSummary(); + if (res.success && res.data) { + setTotalTime(res.data.totalSecs); + } + } catch (err) { + console.error('Failed to fetch reading summary:', err); + } + }; + fetchSummary(); + }, []); + + // Fetch period stats + useEffect(() => { + const fetchStats = async () => { + setLoading(true); + try { + const api = getApiClient(); + const res = await api.getPeriodReadingStats(period); + if (res.success && res.data) { + setStats(res.data); + } + } catch (err) { + console.error('Failed to fetch period stats:', err); + } finally { + setLoading(false); + } + }; + fetchStats(); + }, [period]); + + // Fetch daily hours when period is day + useEffect(() => { + if (period !== 'day') { + setDailyHours(null); + return; + } + const fetchDaily = async () => { + try { + const api = getApiClient(); + const res = await api.getDailyReadingHours(); + if (res.success && res.data) { + setDailyHours(res.data); + } + } catch (err) { + console.error('Failed to fetch daily hours:', err); + } + }; + fetchDaily(); + }, [period]); + + const chartData = useMemo(() => { + if (period === 'day' && dailyHours) { + return dailyHours.hours.map((h) => ({ + label: `${h.hour}时`, + durationSecs: h.durationSecs, + })); + } + if (stats) { + return stats.breakdown; + } + return []; + }, [period, stats, dailyHours]); + + const maxValue = useMemo(() => { + return Math.max(...chartData.map((d) => d.durationSecs), 1); + }, [chartData]); + + return ( + + {/* Header */} + + navigation.goBack()} style={styles.backButton}> + + + 阅读统计 + + + + + {/* Summary Cards */} + + + + 累计阅读 + {formatDuration(totalTime)} + + + + 本周期 + + {stats ? formatDuration(stats.totalDurationSecs) : '0分钟'} + + + + + {/* Period Tabs */} + + {(['day', 'week', 'month', 'year'] as Period[]).map((p) => ( + setPeriod(p)} + > + + {PERIOD_LABELS[p]} + + + ))} + + + {/* Chart */} + + {loading ? ( + + + + ) : ( + <> + + {period === 'day' ? '24小时分布' : `${PERIOD_LABELS[period]}阅读分布`} + + {stats && stats.totalDurationSecs > 0 ? ( + + {chartData.map((item, i) => { + const height = maxValue > 0 ? (item.durationSecs / maxValue) * 100 : 0; + return ( + + + + + + {item.label} + + + ); + })} + + ) : ( + + + 暂无阅读数据 + + {PERIOD_LABELS[period]}内还没有阅读记录 + + + )} + + )} + + + {/* Breakdown List */} + {stats && stats.totalDurationSecs > 0 && ( + + 阅读明细 + {stats.breakdown + .filter((b) => b.durationSecs > 0) + .map((b, i) => ( + + {b.label} + + {formatDuration(b.durationSecs)} + + + ))} + + )} + + + ); +} + +const createStyles = (theme: any) => + StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingTop: spacing.xl + 8, + paddingBottom: spacing.md, + }, + backButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + fontSize: fontSizes.lg, + fontWeight: '600', + }, + content: { + padding: spacing.md, + gap: spacing.md, + }, + summaryCards: { + flexDirection: 'row', + gap: spacing.md, + }, + summaryCard: { + flex: 1, + padding: spacing.lg, + borderRadius: borderRadius.lg, + gap: spacing.xs, + alignItems: 'center', + }, + summaryLabel: { + fontSize: fontSizes.sm, + }, + summaryValue: { + fontSize: fontSizes.lg, + fontWeight: '600', + }, + tabBar: { + flexDirection: 'row', + padding: spacing.xs, + borderRadius: borderRadius.lg, + gap: spacing.xs, + }, + tabItem: { + flex: 1, + alignItems: 'center', + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + }, + tabText: { + fontSize: fontSizes.md, + fontWeight: '500', + }, + chartCard: { + padding: spacing.lg, + borderRadius: borderRadius.lg, + minHeight: 280, + }, + chartTitle: { + fontSize: fontSizes.md, + fontWeight: '600', + marginBottom: spacing.lg, + }, + chartContainer: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + height: 200, + gap: 2, + }, + barContainer: { + flex: 1, + alignItems: 'center', + gap: spacing.xs, + }, + barWrapper: { + width: '100%', + height: 180, + justifyContent: 'flex-end', + }, + bar: { + width: '100%', + borderRadius: 4, + minHeight: 2, + }, + barLabel: { + fontSize: 10, + }, + loadingContainer: { + height: 200, + alignItems: 'center', + justifyContent: 'center', + }, + emptyContainer: { + height: 200, + alignItems: 'center', + justifyContent: 'center', + }, + breakdownCard: { + padding: spacing.lg, + borderRadius: borderRadius.lg, + gap: spacing.sm, + }, + breakdownTitle: { + fontSize: fontSizes.md, + fontWeight: '600', + marginBottom: spacing.sm, + }, + breakdownItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + breakdownValue: { + fontSize: fontSizes.md, + fontWeight: '500', + }, + }); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index b99dea07..44171322 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -1,4 +1,3 @@ -// ============================================================================= // BookDock - Prisma Schema // ============================================================================= // This schema maps to the PostgreSQL database defined in scripts/init-db.sql @@ -61,6 +60,7 @@ model User { sessions Session[] apiKeys ApiKey[] vipMember VipMember? + readingSessions ReadingSession[] @@map("users") } @@ -102,6 +102,7 @@ model Book { lastRead BookLastRead[] bookTags BookTag[] bookAuthors BookAuthor[] + readingSessions ReadingSession[] @@index([title]) @@index([author]) @@ -501,3 +502,22 @@ model SystemConfig { @@map("system_configs") } + +model ReadingSession { + id String @id @default(uuid()) + userId String @map("user_id") + bookId String @map("book_id") + durationSecs Int @map("duration_secs") + date String @map("date") // YYYY-MM-DD format for easy grouping + hour Int? @map("hour") // 0-23 for daily distribution + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) + + @@index([userId, date]) + @@index([userId, bookId, date]) + @@map("reading_sessions") +} + +// ============================================================================= diff --git a/apps/server/src/modules/reading-progress/dto/reading-progress.dto.ts b/apps/server/src/modules/reading-progress/dto/reading-progress.dto.ts index d2bc4002..524374f2 100644 --- a/apps/server/src/modules/reading-progress/dto/reading-progress.dto.ts +++ b/apps/server/src/modules/reading-progress/dto/reading-progress.dto.ts @@ -66,6 +66,13 @@ export class UpdateReadingProgressDto { @Min(0) @IsOptional() scrollOffset?: number; + + @ApiPropertyOptional({ description: 'Reading duration in seconds to add' }) + @Type(() => Number) + @IsInt() + @Min(0) + @IsOptional() + durationSecs?: number; } export class ReadingProgressQueryDto { @@ -244,3 +251,83 @@ export class ReadingStatsDto { @ApiProperty() averageProgressPct: number; } + +// ── Reading Session DTOs ───────────────────────────────────────────────────── + +export class RecordReadingSessionDto { + @ApiProperty({ description: 'Book ID' }) + @IsUUID() + bookId: string; + + @ApiProperty({ description: 'Reading duration in seconds' }) + @Type(() => Number) + @IsInt() + @Min(1) + durationSecs: number; + + @ApiPropertyOptional({ description: 'Hour of day (0-23) for daily distribution' }) + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + @IsOptional() + hour?: number; +} + +export class ReadingStatsQueryDto { + @ApiPropertyOptional({ enum: ['day', 'week', 'month', 'year'] }) + @IsEnum(['day', 'week', 'month', 'year']) + @IsOptional() + period?: 'day' | 'week' | 'month' | 'year' = 'week'; + + @ApiPropertyOptional({ description: 'Reference date (YYYY-MM-DD), defaults to today' }) + @IsString() + @IsOptional() + date?: string; +} + +export class PeriodReadingStatsDto { + @ApiProperty() + period: string; + + @ApiProperty() + totalDurationSecs: number; + + @ApiProperty() + bookCount: number; + + @ApiProperty({ type: [Object] }) + breakdown: Array<{ + label: string; + durationSecs: number; + date: string; + }>; +} + +export class DailyHourStatsDto { + @ApiProperty() + date: string; + + @ApiProperty({ type: [Object] }) + hours: Array<{ + hour: number; + durationSecs: number; + }>; +} + +export class ReadingTimeSummaryDto { + @ApiProperty() + todaySecs: number; + + @ApiProperty() + weekSecs: number; + + @ApiProperty() + monthSecs: number; + + @ApiProperty() + yearSecs: number; + + @ApiProperty() + totalSecs: number; +} diff --git a/apps/server/src/modules/reading-progress/reading-progress.controller.ts b/apps/server/src/modules/reading-progress/reading-progress.controller.ts index 29749db1..bd616173 100644 --- a/apps/server/src/modules/reading-progress/reading-progress.controller.ts +++ b/apps/server/src/modules/reading-progress/reading-progress.controller.ts @@ -20,6 +20,11 @@ import { BookmarkResponseDto, SyncReadingDto, ReadingStatsDto, + RecordReadingSessionDto, + ReadingStatsQueryDto, + PeriodReadingStatsDto, + DailyHourStatsDto, + ReadingTimeSummaryDto, } from './dto/reading-progress.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -79,6 +84,45 @@ export class ReadingProgressController { return this.progressService.getStats(userId); } + // ── Reading Session Endpoints ─────────────────────────────────────────────── + + @Post('session') + @ApiOperation({ summary: 'Record a reading session' }) + @ApiResponse({ status: 200 }) + async recordSession( + @Body() dto: RecordReadingSessionDto, + @CurrentUser('sub') userId: string, + ) { + return this.progressService.recordSession(userId, dto); + } + + @Get('time-summary') + @ApiOperation({ summary: 'Get reading time summary (today/week/month/year/total)' }) + @ApiResponse({ status: 200, type: ReadingTimeSummaryDto }) + async getTimeSummary(@CurrentUser('sub') userId: string) { + return this.progressService.getReadingTimeSummary(userId); + } + + @Get('period-stats') + @ApiOperation({ summary: 'Get period reading stats with breakdown' }) + @ApiResponse({ status: 200, type: PeriodReadingStatsDto }) + async getPeriodStats( + @CurrentUser('sub') userId: string, + @Query() query: ReadingStatsQueryDto, + ) { + return this.progressService.getPeriodStats(userId, query.period || 'week', query.date); + } + + @Get('daily-hours') + @ApiOperation({ summary: 'Get daily reading hours distribution (24h)' }) + @ApiResponse({ status: 200, type: DailyHourStatsDto }) + async getDailyHours( + @CurrentUser('sub') userId: string, + @Query('date') date?: string, + ) { + return this.progressService.getDailyHourStats(userId, date); + } + // ── Bookmarks ─────────────────────────────────────────────────────────────── @Post('bookmarks') diff --git a/apps/server/src/modules/reading-progress/reading-progress.service.ts b/apps/server/src/modules/reading-progress/reading-progress.service.ts index 3ea171d2..e178b618 100644 --- a/apps/server/src/modules/reading-progress/reading-progress.service.ts +++ b/apps/server/src/modules/reading-progress/reading-progress.service.ts @@ -12,12 +12,22 @@ import { ReadingProgressResponseDto, ReadingStatsDto, UpdateReadingProgressDto, + RecordReadingSessionDto, + ReadingTimeSummaryDto, + PeriodReadingStatsDto, + DailyHourStatsDto, } from './dto/reading-progress.dto'; @Injectable() export class ReadingProgressService { constructor(@Inject(PRISMA_CLIENT) private readonly prisma: PrismaClient) {} + // VIP check removed - VIP status is managed by external Plus API + // Client controls feature access based on local isVip state + private async checkVip(_userId: string): Promise { + return true; + } + async upsert( userId: string, bookId: string, @@ -36,6 +46,23 @@ export class ReadingProgressService { } } + // Build update data + const updateData: any = { + status, + lastReadAt: new Date(), + }; + if (dto.epubCfi !== undefined) updateData.epubCfi = dto.epubCfi; + if (dto.pdfPage !== undefined) updateData.pdfPage = dto.pdfPage; + if (dto.mobiLocation !== undefined) updateData.mobiLocation = dto.mobiLocation; + if (dto.bookmarkNote !== undefined) updateData.bookmarkNote = dto.bookmarkNote; + if (dto.progressPct !== undefined) updateData.progressPct = dto.progressPct; + if (dto.currentChapter !== undefined) updateData.currentChapter = dto.currentChapter; + if (dto.scrollOffset !== undefined) updateData.scrollOffset = dto.scrollOffset; + // Increment timeSpentSecs if durationSecs provided + if (dto.durationSecs && dto.durationSecs > 0) { + updateData.timeSpentSecs = { increment: dto.durationSecs }; + } + const progress = await this.prisma.readingProgress.upsert({ where: { userId_bookId: { userId, bookId } }, create: { @@ -49,19 +76,10 @@ export class ReadingProgressService { progressPct: dto.progressPct ?? 0, currentChapter: dto.currentChapter ?? 0, scrollOffset: dto.scrollOffset ?? 0, + timeSpentSecs: dto.durationSecs ?? 0, lastReadAt: new Date(), }, - update: { - status, - ...(dto.epubCfi !== undefined && { epubCfi: dto.epubCfi }), - ...(dto.pdfPage !== undefined && { pdfPage: dto.pdfPage }), - ...(dto.mobiLocation !== undefined && { mobiLocation: dto.mobiLocation }), - ...(dto.bookmarkNote !== undefined && { bookmarkNote: dto.bookmarkNote }), - ...(dto.progressPct !== undefined && { progressPct: dto.progressPct }), - ...(dto.currentChapter !== undefined && { currentChapter: dto.currentChapter }), - ...(dto.scrollOffset !== undefined && { scrollOffset: dto.scrollOffset }), - lastReadAt: new Date(), - }, + update: updateData, include: { book: { select: { id: true, title: true, author: true, coverUrl: true, format: true } } }, }); @@ -220,6 +238,251 @@ export class ReadingProgressService { }; } + // ── Reading Session Methods ─────────────────────────────────────────────── + + async recordSession( + userId: string, + dto: RecordReadingSessionDto, + ): Promise<{ success: boolean; message: string }> { + const isVip = await this.checkVip(userId); + if (!isVip) { + return { success: false, message: 'VIP required' }; + } + + const book = await this.prisma.book.findUnique({ where: { id: dto.bookId, isDeleted: false } }); + if (!book) throw new NotFoundException(`Book ${dto.bookId} not found`); + + const now = new Date(); + const date = now.toISOString().split('T')[0]; // YYYY-MM-DD + const hour = dto.hour ?? now.getHours(); + + await this.prisma.readingSession.create({ + data: { + userId, + bookId: dto.bookId, + durationSecs: dto.durationSecs, + date, + hour, + }, + }); + + // Also update the cumulative timeSpentSecs in ReadingProgress + await this.prisma.readingProgress.upsert({ + where: { userId_bookId: { userId, bookId: dto.bookId } }, + create: { + userId, + bookId: dto.bookId, + status: 'reading', + timeSpentSecs: dto.durationSecs, + lastReadAt: now, + }, + update: { + timeSpentSecs: { increment: dto.durationSecs }, + lastReadAt: now, + }, + }); + + return { success: true, message: 'Reading session recorded' }; + } + + async getReadingTimeSummary(userId: string): Promise { + const isVip = await this.checkVip(userId); + if (!isVip) { + return { todaySecs: 0, weekSecs: 0, monthSecs: 0, yearSecs: 0, totalSecs: 0 }; + } const now = new Date(); + const today = now.toISOString().split('T')[0]; + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - now.getDay() + (now.getDay() === 0 ? -6 : 1)); // Monday + weekStart.setHours(0, 0, 0, 0); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const yearStart = new Date(now.getFullYear(), 0, 1); + + const [todayRes, weekRes, monthRes, yearRes, totalRes] = await Promise.all([ + this.prisma.readingSession.aggregate({ + _sum: { durationSecs: true }, + where: { userId, date: today }, + }), + this.prisma.readingSession.aggregate({ + _sum: { durationSecs: true }, + where: { userId, date: { gte: weekStart.toISOString().split('T')[0] } }, + }), + this.prisma.readingSession.aggregate({ + _sum: { durationSecs: true }, + where: { userId, date: { gte: monthStart.toISOString().split('T')[0] } }, + }), + this.prisma.readingSession.aggregate({ + _sum: { durationSecs: true }, + where: { userId, date: { gte: yearStart.toISOString().split('T')[0] } }, + }), + this.prisma.readingSession.aggregate({ + _sum: { durationSecs: true }, + where: { userId }, + }), + ]); + + return { + todaySecs: todayRes._sum.durationSecs || 0, + weekSecs: weekRes._sum.durationSecs || 0, + monthSecs: monthRes._sum.durationSecs || 0, + yearSecs: yearRes._sum.durationSecs || 0, + totalSecs: totalRes._sum.durationSecs || 0, + }; + } + + async getPeriodStats( + userId: string, + period: 'day' | 'week' | 'month' | 'year', + refDate?: string, + ): Promise { + const isVip = await this.checkVip(userId); + if (!isVip) { + return { + period, + totalDurationSecs: 0, + bookCount: 0, + breakdown: [], + }; + } + + const date = refDate || new Date().toISOString().split('T')[0]; + const d = new Date(date); + + let startDate: Date; + let endDate: Date; + let labels: string[] = []; + let dateKeys: string[] = []; + + switch (period) { + case 'day': { + // For day period, query by hour and return 24-hour breakdown + const targetDate = date; + const hourSessions = await this.prisma.readingSession.groupBy({ + by: ['hour'], + _sum: { durationSecs: true }, + where: { userId, date: targetDate }, + }); + const hourMap = new Map(hourSessions.map((s) => [s.hour, s._sum.durationSecs || 0])); + const breakdown = Array.from({ length: 24 }, (_, i) => ({ + label: `${i}时`, + durationSecs: hourMap.get(i) || 0, + date: targetDate, + })); + const totalDurationSecs = breakdown.reduce((sum, b) => sum + b.durationSecs, 0); + const bookCount = await this.prisma.readingSession.groupBy({ + by: ['bookId'], + where: { userId, date: targetDate }, + }).then((groups) => groups.length); + return { period, totalDurationSecs, bookCount, breakdown }; + } + case 'week': { + const dayOfWeek = d.getDay() || 7; + startDate = new Date(d); + startDate.setDate(d.getDate() - dayOfWeek + 1); + startDate.setHours(0, 0, 0, 0); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + 7); + labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + dateKeys = Array.from({ length: 7 }, (_, i) => { + const dd = new Date(startDate); + dd.setDate(dd.getDate() + i); + return dd.toISOString().split('T')[0]; + }); + break; + } + case 'month': { + startDate = new Date(d.getFullYear(), d.getMonth(), 1); + endDate = new Date(d.getFullYear(), d.getMonth() + 1, 1); + const daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + labels = Array.from({ length: daysInMonth }, (_, i) => `${i + 1}日`); + dateKeys = Array.from({ length: daysInMonth }, (_, i) => { + const dd = new Date(d.getFullYear(), d.getMonth(), i + 1); + return dd.toISOString().split('T')[0]; + }); + break; + } + case 'year': { + startDate = new Date(d.getFullYear(), 0, 1); + endDate = new Date(d.getFullYear() + 1, 0, 1); + labels = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; + dateKeys = Array.from({ length: 12 }, (_, i) => { + const dd = new Date(d.getFullYear(), i, 1); + return dd.toISOString().split('T')[0].slice(0, 7); // YYYY-MM + }); + break; + } + } + + const startDateStr = startDate.toISOString().split('T')[0]; + const endDateStr = endDate.toISOString().split('T')[0]; + + const sessions = await this.prisma.readingSession.groupBy({ + by: ['date'], + _sum: { durationSecs: true }, + where: { + userId, + date: { gte: startDateStr, lt: endDateStr }, + }, + }); + + const sessionMap = new Map(sessions.map((s) => [s.date, s._sum.durationSecs || 0])); + + const breakdown = labels.map((label, i) => { + const key = period === 'year' ? dateKeys[i] : dateKeys[i]; + const durationSecs = period === 'year' + ? (sessionMap.get(key) || 0) + : (sessionMap.get(dateKeys[i]) || 0); + return { label, durationSecs, date: key }; + }); + + const totalDurationSecs = breakdown.reduce((sum, b) => sum + b.durationSecs, 0); + + // Count unique books in this period + const bookCount = await this.prisma.readingSession.groupBy({ + by: ['bookId'], + where: { + userId, + date: { gte: startDateStr, lt: endDateStr }, + }, + }).then((groups) => groups.length); + + return { + period, + totalDurationSecs, + bookCount, + breakdown, + }; + } + + async getDailyHourStats( + userId: string, + date?: string, + ): Promise { + const isVip = await this.checkVip(userId); + if (!isVip) { + return { + date: date || new Date().toISOString().split('T')[0], + hours: Array.from({ length: 24 }, (_, i) => ({ hour: i, durationSecs: 0 })), + }; + } + + const targetDate = date || new Date().toISOString().split('T')[0]; + + const sessions = await this.prisma.readingSession.groupBy({ + by: ['hour'], + _sum: { durationSecs: true }, + where: { userId, date: targetDate }, + }); + + const sessionMap = new Map(sessions.map((s) => [s.hour, s._sum.durationSecs || 0])); + + const hours = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + durationSecs: sessionMap.get(i) || 0, + })); + + return { date: targetDate, hours }; + } + private toProgressResponse(p: any): ReadingProgressResponseDto { return { id: p.id, diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index ebc8df24..ec3e6fbe 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -81,6 +81,33 @@ export interface ReadingSession { endedAt?: string; } +export interface ReadingTimeSummary { + todaySecs: number; + weekSecs: number; + monthSecs: number; + yearSecs: number; + totalSecs: number; +} + +export interface PeriodReadingStats { + period: 'day' | 'week' | 'month' | 'year'; + totalDurationSecs: number; + bookCount: number; + breakdown: Array<{ + label: string; + durationSecs: number; + date: string; + }>; +} + +export interface DailyHourStats { + date: string; + hours: Array<{ + hour: number; + durationSecs: number; + }>; +} + export interface TTSVoice { id: string; name: string; @@ -366,11 +393,12 @@ class ApiClient { } // Reading progress - async updateReadingProgress(bookId: string, progressPct: number, currentChapter?: number, scrollOffset?: number): Promise { + async updateReadingProgress(bookId: string, progressPct: number, currentChapter?: number, scrollOffset?: number, durationSecs?: number): Promise { const { data } = await this.client.post(`/reading-progress/books/${bookId}`, { progressPct, currentChapter, scrollOffset, + durationSecs, }); return data; } @@ -385,6 +413,28 @@ class ApiClient { return data; } + // ── Reading Time Tracking ─────────────────────────────────────────── + + async recordReadingSession(bookId: string, durationSecs: number, hour?: number): Promise { + const { data } = await this.client.post('/reading-progress/session', { bookId, durationSecs, hour }); + return data; + } + + async getReadingTimeSummary(): Promise> { + const { data } = await this.client.get('/reading-progress/time-summary'); + return data; + } + + async getPeriodReadingStats(period: 'day' | 'week' | 'month' | 'year', date?: string): Promise> { + const { data } = await this.client.get('/reading-progress/period-stats', { params: { period, date } }); + return data; + } + + async getDailyReadingHours(date?: string): Promise> { + const { data } = await this.client.get('/reading-progress/daily-hours', { params: { date } }); + return data; + } + // TTS endpoints async getTtsProviders(): Promise> { const { data } = await this.client.get('/tts/providers'); diff --git a/scripts/init-db.sql b/scripts/init-db.sql index 75c04249..f901feae 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -152,6 +152,20 @@ CREATE INDEX idx_reading_progress_user ON reading_progress(user_id); CREATE INDEX idx_reading_progress_book ON reading_progress(book_id); CREATE INDEX idx_reading_progress_last_read ON reading_progress(last_read_at DESC); +-- ── Reading Sessions ────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS reading_sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + book_id UUID NOT NULL REFERENCES books(id) ON DELETE CASCADE, + duration_secs INTEGER NOT NULL DEFAULT 0, + date DATE NOT NULL, + hour INTEGER, -- 0-23 for daily distribution + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_reading_sessions_user_date ON reading_sessions(user_id, date); +CREATE INDEX idx_reading_sessions_user_book_date ON reading_sessions(user_id, book_id, date); + -- ── Bookmarks ───────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS bookmarks ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),