feat(analytics): add reading statistics dashboard
- Add reading stats endpoint with daily/monthly history - Add device usage statistics (sync count, time spent) - Add popular books view with completion rates - Server-side rendered analytics page with HTMX - Date range filtering for reading history
This commit is contained in:
@@ -0,0 +1,309 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookmann/internal/database"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AnalyticsHandler struct {
|
||||||
|
db *database.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAnalyticsHandler(db *database.Queries) *AnalyticsHandler {
|
||||||
|
return &AnalyticsHandler{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadingStatsResponse struct {
|
||||||
|
TotalBooksRead int `json:"total_books_read"`
|
||||||
|
TotalPagesRead int `json:"total_pages_read"`
|
||||||
|
TotalReadingTime int `json:"total_reading_time_minutes"`
|
||||||
|
AverageSessionTime float64 `json:"average_session_time_minutes"`
|
||||||
|
LongestSession int `json:"longest_session_minutes"`
|
||||||
|
MostActiveDay string `json:"most_active_day_of_week"`
|
||||||
|
CompletionRate float64 `json:"completion_rate"`
|
||||||
|
DailyReadingMinutes []DailyReading `json:"daily_reading_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyReading struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
Pages int `json:"pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceUsageResponse struct {
|
||||||
|
Devices []DeviceUsage `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceUsage struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceType string `json:"device_type"`
|
||||||
|
SyncCount int `json:"sync_count"`
|
||||||
|
LastSync string `json:"last_sync"`
|
||||||
|
TotalTimeSeconds int `json:"total_time_seconds"`
|
||||||
|
TotalTimeMinutes float64 `json:"total_time_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PopularBooksResponse struct {
|
||||||
|
Books []PopularBook `json:"books"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PopularBook struct {
|
||||||
|
MediaItemID string `json:"media_item_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
ReadCount int `json:"read_count"`
|
||||||
|
AvgCompletion float64 `json:"avg_completion"`
|
||||||
|
LastRead string `json:"last_read"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetReadingStats(c echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
|
||||||
|
startDate := c.QueryParam("start_date")
|
||||||
|
endDate := c.QueryParam("end_date")
|
||||||
|
|
||||||
|
if startDate == "" {
|
||||||
|
startDate = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if endDate == "" {
|
||||||
|
endDate = time.Now().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime, err := time.Parse("2006-01-02", startDate)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
|
||||||
|
}
|
||||||
|
|
||||||
|
endTime, err := time.Parse("2006-01-02", endDate)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CreatedAt: pgtype.Timestamptz{Time: startTime, Valid: true},
|
||||||
|
CreatedAt_2: pgtype.Timestamptz{Time: endTime, Valid: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil && err != pgx.ErrNoRows {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get reading history")
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := h.calculateReadingStats(history)
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadingHistoryRow) ReadingStatsResponse {
|
||||||
|
stats := ReadingStatsResponse{
|
||||||
|
DailyReadingMinutes: make([]DailyReading, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
totalTime := 0
|
||||||
|
totalPages := 0
|
||||||
|
longestSession := 0
|
||||||
|
sessionTimes := make([]int, 0)
|
||||||
|
|
||||||
|
dailyMap := make(map[string]*DailyReading)
|
||||||
|
|
||||||
|
for _, entry := range history {
|
||||||
|
if entry.TimeSpentSeconds.Valid {
|
||||||
|
minutes := entry.TimeSpentSeconds.Int32 / 60
|
||||||
|
totalTime += int(minutes)
|
||||||
|
sessionTimes = append(sessionTimes, int(minutes))
|
||||||
|
|
||||||
|
if int(minutes) > longestSession {
|
||||||
|
longestSession = int(minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
|
||||||
|
if dailyMap[dateKey] == nil {
|
||||||
|
dailyMap[dateKey] = &DailyReading{
|
||||||
|
Date: dateKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dailyMap[dateKey].Minutes += int(minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.PagesRead.Valid {
|
||||||
|
totalPages += int(entry.PagesRead.Int32)
|
||||||
|
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
|
||||||
|
if dailyMap[dateKey] != nil {
|
||||||
|
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.TotalReadingTime = totalTime
|
||||||
|
stats.TotalPagesRead = totalPages
|
||||||
|
stats.LongestSession = longestSession
|
||||||
|
|
||||||
|
if len(sessionTimes) > 0 {
|
||||||
|
sum := 0
|
||||||
|
for _, t := range sessionTimes {
|
||||||
|
sum += t
|
||||||
|
}
|
||||||
|
stats.AverageSessionTime = float64(sum) / float64(len(sessionTimes))
|
||||||
|
}
|
||||||
|
|
||||||
|
booksCompleted := 0
|
||||||
|
totalBooks := len(history)
|
||||||
|
if totalBooks > 0 {
|
||||||
|
for _, entry := range history {
|
||||||
|
if entry.ProgressPercentage.Valid && entry.ProgressPercentage.Float64 >= 100.0 {
|
||||||
|
booksCompleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.TotalBooksRead = booksCompleted
|
||||||
|
if totalBooks > 0 {
|
||||||
|
stats.CompletionRate = float64(booksCompleted) / float64(totalBooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.MostActiveDay = h.calculateMostActiveDay(history)
|
||||||
|
|
||||||
|
for _, data := range dailyMap {
|
||||||
|
stats.DailyReadingMinutes = append(stats.DailyReadingMinutes, *data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserReadingHistoryRow) string {
|
||||||
|
dayCount := make(map[string]int)
|
||||||
|
|
||||||
|
days := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||||
|
|
||||||
|
for _, entry := range history {
|
||||||
|
day := days[entry.CreatedAt.Time.Weekday()]
|
||||||
|
dayCount[day]++
|
||||||
|
}
|
||||||
|
|
||||||
|
mostActive := "Monday"
|
||||||
|
maxCount := 0
|
||||||
|
|
||||||
|
for day, count := range dayCount {
|
||||||
|
if count > maxCount {
|
||||||
|
maxCount = count
|
||||||
|
mostActive = day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mostActive
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetDeviceUsage(c echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
|
||||||
|
if err != nil && err != pgx.ErrNoRows {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get device usage")
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := make([]DeviceUsage, 0, len(usage))
|
||||||
|
|
||||||
|
for _, u := range usage {
|
||||||
|
lastSync := ""
|
||||||
|
if u.LastSync != nil {
|
||||||
|
if t, ok := u.LastSync.(time.Time); ok {
|
||||||
|
lastSync = t.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalTimeMinutes := float64(u.TotalTimeSeconds) / 60.0
|
||||||
|
|
||||||
|
devices = append(devices, DeviceUsage{
|
||||||
|
DeviceID: formatUUID(u.ID),
|
||||||
|
DeviceName: u.DeviceName,
|
||||||
|
DeviceType: u.DeviceType,
|
||||||
|
SyncCount: int(u.SyncCount),
|
||||||
|
LastSync: lastSync,
|
||||||
|
TotalTimeSeconds: int(u.TotalTimeSeconds),
|
||||||
|
TotalTimeMinutes: totalTimeMinutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, DeviceUsageResponse{
|
||||||
|
Devices: devices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetPopularBooks(c echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
|
||||||
|
limit := c.QueryParam("limit")
|
||||||
|
if limit == "" {
|
||||||
|
limit = "10"
|
||||||
|
}
|
||||||
|
|
||||||
|
limitInt := int32(10)
|
||||||
|
if parsedLimit, err := parseLimit(limit); err == nil {
|
||||||
|
limitInt = parsedLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
Limit: limitInt,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil && err != pgx.ErrNoRows {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get popular books")
|
||||||
|
}
|
||||||
|
|
||||||
|
popularBooks := make([]PopularBook, 0, len(books))
|
||||||
|
|
||||||
|
for _, book := range books {
|
||||||
|
lastRead := ""
|
||||||
|
if book.LastRead != nil {
|
||||||
|
if t, ok := book.LastRead.(time.Time); ok {
|
||||||
|
lastRead = t.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
popularBooks = append(popularBooks, PopularBook{
|
||||||
|
MediaItemID: formatUUID(book.ID),
|
||||||
|
Title: book.Title,
|
||||||
|
Author: book.Author.String,
|
||||||
|
ReadCount: int(book.ReadCount),
|
||||||
|
AvgCompletion: book.AvgCompletion,
|
||||||
|
LastRead: lastRead,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, PopularBooksResponse{
|
||||||
|
Books: popularBooks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLimit(limitStr string) (int32, error) {
|
||||||
|
var limit int32
|
||||||
|
if _, err := fmt.Sscanf(limitStr, "%d", &limit); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return limit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUUID(id pgtype.UUID) string {
|
||||||
|
if !id.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
u := uuid.UUID(id.Bytes)
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
templ Analytics(user User) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reading Analytics - Bookmann</title>
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/toast.js"></script>
|
||||||
|
<link href="/static/style.css" rel="stylesheet">
|
||||||
|
<script src="/static/theme.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<style>
|
||||||
|
.stat-card {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="theme-{ user.Theme }">
|
||||||
|
@Header(user, "/analytics")
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">📊 Reading Analytics</h1>
|
||||||
|
<p style="color: var(--text-secondary)">Track your reading habits and device usage</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Range Picker -->
|
||||||
|
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Start Date</label>
|
||||||
|
<input type="date" id="start-date" onchange="loadAnalytics()"
|
||||||
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">End Date</label>
|
||||||
|
<input type="date" id="end-date" onchange="loadAnalytics()"
|
||||||
|
class="w-full px-4 py-2 border rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button onclick="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container">
|
||||||
|
<div class="stat-card card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Books Read</h3>
|
||||||
|
<p id="total-books" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Pages Read</h3>
|
||||||
|
<p id="total-pages" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Reading Time</h3>
|
||||||
|
<p id="reading-time" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
|
||||||
|
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- Daily Reading Chart -->
|
||||||
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Daily Reading Minutes</h3>
|
||||||
|
<div class="h-80">
|
||||||
|
<canvas id="daily-reading-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Usage Chart -->
|
||||||
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Device Usage</h3>
|
||||||
|
<div class="h-80">
|
||||||
|
<canvas id="device-usage-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Popular Books -->
|
||||||
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Most Read Books</h3>
|
||||||
|
<div id="popular-books" class="space-y-3">
|
||||||
|
<div class="text-center py-8" style="color: var(--text-secondary);">
|
||||||
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
|
<p>Loading analytics...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/analytics.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.977
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
func Analytics(user User) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Reading Analytics - Bookmann</title><script src=\"/static/htmx.min.js\"></script><script src=\"/static/toast.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><script src=\"/static/theme.js\"></script><script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script><style>\n .stat-card {\n transition: transform 0.2s ease, box-shadow 0.2s ease;\n }\n .stat-card:hover {\n transform: translateY(-2px);\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n }\n </style></head><body class=\"theme-{ user.Theme }\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Header(user, "/analytics").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">📊 Reading Analytics</h1><p style=\"color: var(--text-secondary)\">Track your reading habits and device usage</p></div><!-- Date Range Picker --><div class=\"card p-4 rounded-lg border mb-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex flex-wrap gap-4 items-center\"><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary);\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary);\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div class=\"flex items-end\"><button onclick=\"loadAnalytics()\" class=\"btn-primary px-6 py-2 rounded-lg\">Update</button></div></div></div><!-- Stats Cards --><div class=\"grid grid-cols-1 md:grid-cols-4 gap-6 mb-8\" id=\"stats-container\"><div class=\"stat-card card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Books Read</h3><p id=\"total-books\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"stat-card card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Pages Read</h3><p id=\"total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"stat-card card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Reading Time</h3><p id=\"reading-time\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"stat-card card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Completion Rate</h3><p id=\"completion-rate\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div></div><!-- Charts Row --><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><!-- Daily Reading Chart --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><!-- Device Usage Chart --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><!-- Popular Books --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8\" style=\"color: var(--text-secondary);\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div><script src=\"/static/analytics.js\"></script></body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
Reference in New Issue
Block a user