wip 15
This commit is contained in:
@@ -5,6 +5,14 @@ Updating Go templates (rendered HTML) → React app using V1 API (OpenAPI spec)
|
|||||||
|
|
||||||
## Critical Rules
|
## Critical Rules
|
||||||
|
|
||||||
|
### Generated Files
|
||||||
|
- **NEVER edit generated files** - Always edit the source and regenerate
|
||||||
|
- Go backend API: Edit `api/v1/openapi.yaml` then run `go generate ./api/v1/generate.go`
|
||||||
|
- TS client: Regenerate with `cd frontend && npm run generate:api`
|
||||||
|
- Examples of generated files:
|
||||||
|
- `api/v1/api.gen.go`
|
||||||
|
- `frontend/src/generated/**/*.ts`
|
||||||
|
|
||||||
### Database Access
|
### Database Access
|
||||||
- **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql`
|
- **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql`
|
||||||
- Migrate V1 API by mirroring legacy implementation in `api/app-admin-routes.go` and `api/app-routes.go`
|
- Migrate V1 API by mirroring legacy implementation in `api/app-admin-routes.go` and `api/app-routes.go`
|
||||||
|
|||||||
@@ -280,6 +280,13 @@ type ImportResultsResponse struct {
|
|||||||
// ImportType defines model for ImportType.
|
// ImportType defines model for ImportType.
|
||||||
type ImportType string
|
type ImportType string
|
||||||
|
|
||||||
|
// InfoResponse defines model for InfoResponse.
|
||||||
|
type InfoResponse struct {
|
||||||
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
|
SearchEnabled bool `json:"search_enabled"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
// LeaderboardData defines model for LeaderboardData.
|
// LeaderboardData defines model for LeaderboardData.
|
||||||
type LeaderboardData struct {
|
type LeaderboardData struct {
|
||||||
All []LeaderboardEntry `json:"all"`
|
All []LeaderboardEntry `json:"all"`
|
||||||
@@ -291,7 +298,7 @@ type LeaderboardData struct {
|
|||||||
// LeaderboardEntry defines model for LeaderboardEntry.
|
// LeaderboardEntry defines model for LeaderboardEntry.
|
||||||
type LeaderboardEntry struct {
|
type LeaderboardEntry struct {
|
||||||
UserId string `json:"user_id"`
|
UserId string `json:"user_id"`
|
||||||
Value int64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogEntry defines model for LogEntry.
|
// LogEntry defines model for LogEntry.
|
||||||
@@ -598,6 +605,9 @@ type ServerInterface interface {
|
|||||||
// Get user streaks
|
// Get user streaks
|
||||||
// (GET /home/streaks)
|
// (GET /home/streaks)
|
||||||
GetStreaks(w http.ResponseWriter, r *http.Request)
|
GetStreaks(w http.ResponseWriter, r *http.Request)
|
||||||
|
// Get server information
|
||||||
|
// (GET /info)
|
||||||
|
GetInfo(w http.ResponseWriter, r *http.Request)
|
||||||
// List progress records
|
// List progress records
|
||||||
// (GET /progress)
|
// (GET /progress)
|
||||||
GetProgressList(w http.ResponseWriter, r *http.Request, params GetProgressListParams)
|
GetProgressList(w http.ResponseWriter, r *http.Request, params GetProgressListParams)
|
||||||
@@ -1174,6 +1184,20 @@ func (siw *ServerInterfaceWrapper) GetStreaks(w http.ResponseWriter, r *http.Req
|
|||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInfo operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) GetInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.GetInfo(w, r)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// GetProgressList operation middleware
|
// GetProgressList operation middleware
|
||||||
func (siw *ServerInterfaceWrapper) GetProgressList(w http.ResponseWriter, r *http.Request) {
|
func (siw *ServerInterfaceWrapper) GetProgressList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
@@ -1510,6 +1534,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
|||||||
m.HandleFunc("GET "+options.BaseURL+"/home/graph", wrapper.GetGraphData)
|
m.HandleFunc("GET "+options.BaseURL+"/home/graph", wrapper.GetGraphData)
|
||||||
m.HandleFunc("GET "+options.BaseURL+"/home/statistics", wrapper.GetUserStatistics)
|
m.HandleFunc("GET "+options.BaseURL+"/home/statistics", wrapper.GetUserStatistics)
|
||||||
m.HandleFunc("GET "+options.BaseURL+"/home/streaks", wrapper.GetStreaks)
|
m.HandleFunc("GET "+options.BaseURL+"/home/streaks", wrapper.GetStreaks)
|
||||||
|
m.HandleFunc("GET "+options.BaseURL+"/info", wrapper.GetInfo)
|
||||||
m.HandleFunc("GET "+options.BaseURL+"/progress", wrapper.GetProgressList)
|
m.HandleFunc("GET "+options.BaseURL+"/progress", wrapper.GetProgressList)
|
||||||
m.HandleFunc("GET "+options.BaseURL+"/progress/{id}", wrapper.GetProgress)
|
m.HandleFunc("GET "+options.BaseURL+"/progress/{id}", wrapper.GetProgress)
|
||||||
m.HandleFunc("GET "+options.BaseURL+"/search", wrapper.GetSearch)
|
m.HandleFunc("GET "+options.BaseURL+"/search", wrapper.GetSearch)
|
||||||
@@ -2350,6 +2375,31 @@ func (response GetStreaks500JSONResponse) VisitGetStreaksResponse(w http.Respons
|
|||||||
return json.NewEncoder(w).Encode(response)
|
return json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetInfoRequestObject struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetInfoResponseObject interface {
|
||||||
|
VisitGetInfoResponse(w http.ResponseWriter) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetInfo200JSONResponse InfoResponse
|
||||||
|
|
||||||
|
func (response GetInfo200JSONResponse) VisitGetInfoResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetInfo500JSONResponse ErrorResponse
|
||||||
|
|
||||||
|
func (response GetInfo500JSONResponse) VisitGetInfoResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(500)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
type GetProgressListRequestObject struct {
|
type GetProgressListRequestObject struct {
|
||||||
Params GetProgressListParams
|
Params GetProgressListParams
|
||||||
}
|
}
|
||||||
@@ -2650,6 +2700,9 @@ type StrictServerInterface interface {
|
|||||||
// Get user streaks
|
// Get user streaks
|
||||||
// (GET /home/streaks)
|
// (GET /home/streaks)
|
||||||
GetStreaks(ctx context.Context, request GetStreaksRequestObject) (GetStreaksResponseObject, error)
|
GetStreaks(ctx context.Context, request GetStreaksRequestObject) (GetStreaksResponseObject, error)
|
||||||
|
// Get server information
|
||||||
|
// (GET /info)
|
||||||
|
GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error)
|
||||||
// List progress records
|
// List progress records
|
||||||
// (GET /progress)
|
// (GET /progress)
|
||||||
GetProgressList(ctx context.Context, request GetProgressListRequestObject) (GetProgressListResponseObject, error)
|
GetProgressList(ctx context.Context, request GetProgressListRequestObject) (GetProgressListResponseObject, error)
|
||||||
@@ -3260,6 +3313,30 @@ func (sh *strictHandler) GetStreaks(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInfo operation middleware
|
||||||
|
func (sh *strictHandler) GetInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request GetInfoRequestObject
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||||
|
return sh.ssi.GetInfo(ctx, request.(GetInfoRequestObject))
|
||||||
|
}
|
||||||
|
for _, middleware := range sh.middlewares {
|
||||||
|
handler = middleware(handler, "GetInfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := handler(r.Context(), w, r, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
} else if validResponse, ok := response.(GetInfoResponseObject); ok {
|
||||||
|
if err := validResponse.VisitGetInfoResponse(w); err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
}
|
||||||
|
} else if response != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetProgressList operation middleware
|
// GetProgressList operation middleware
|
||||||
func (sh *strictHandler) GetProgressList(w http.ResponseWriter, r *http.Request, params GetProgressListParams) {
|
func (sh *strictHandler) GetProgressList(w http.ResponseWriter, r *http.Request, params GetProgressListParams) {
|
||||||
var request GetProgressListRequestObject
|
var request GetProgressListRequestObject
|
||||||
|
|||||||
@@ -174,66 +174,66 @@ func convertGraphData(graphData []database.GetDailyReadStatsRow) []GraphDataPoin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) UserStatisticsResponse {
|
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) UserStatisticsResponse {
|
||||||
// Sort helper - sort by WPM
|
// Sort by WPM for each period
|
||||||
sortByWPM := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
|
sortByWPM := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) float64) []LeaderboardEntry {
|
||||||
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||||
sort.SliceStable(sorted, func(i, j int) bool {
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
return sorted[i].TotalWpm > sorted[j].TotalWpm
|
return getter(sorted[i]) > getter(sorted[j])
|
||||||
})
|
})
|
||||||
|
|
||||||
result := make([]LeaderboardEntry, len(sorted))
|
result := make([]LeaderboardEntry, len(sorted))
|
||||||
for i, item := range sorted {
|
for i, item := range sorted {
|
||||||
result[i] = LeaderboardEntry{UserId: item.UserID, Value: int64(item.TotalWpm)}
|
result[i] = LeaderboardEntry{UserId: item.UserID, Value: getter(item)}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by duration (seconds)
|
// Sort by duration (seconds) for each period
|
||||||
sortByDuration := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
|
sortByDuration := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
|
||||||
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||||
sort.SliceStable(sorted, func(i, j int) bool {
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
return sorted[i].TotalSeconds > sorted[j].TotalSeconds
|
return getter(sorted[i]) > getter(sorted[j])
|
||||||
})
|
})
|
||||||
|
|
||||||
result := make([]LeaderboardEntry, len(sorted))
|
result := make([]LeaderboardEntry, len(sorted))
|
||||||
for i, item := range sorted {
|
for i, item := range sorted {
|
||||||
result[i] = LeaderboardEntry{UserId: item.UserID, Value: item.TotalSeconds}
|
result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by words
|
// Sort by words for each period
|
||||||
sortByWords := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
|
sortByWords := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
|
||||||
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
|
||||||
sort.SliceStable(sorted, func(i, j int) bool {
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
return sorted[i].TotalWordsRead > sorted[j].TotalWordsRead
|
return getter(sorted[i]) > getter(sorted[j])
|
||||||
})
|
})
|
||||||
|
|
||||||
result := make([]LeaderboardEntry, len(sorted))
|
result := make([]LeaderboardEntry, len(sorted))
|
||||||
for i, item := range sorted {
|
for i, item := range sorted {
|
||||||
result[i] = LeaderboardEntry{UserId: item.UserID, Value: item.TotalWordsRead}
|
result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
return UserStatisticsResponse{
|
return UserStatisticsResponse{
|
||||||
Wpm: LeaderboardData{
|
Wpm: LeaderboardData{
|
||||||
All: sortByWPM(userStatistics),
|
All: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.TotalWpm }),
|
||||||
Year: sortByWPM(userStatistics),
|
Year: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.YearlyWpm }),
|
||||||
Month: sortByWPM(userStatistics),
|
Month: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.MonthlyWpm }),
|
||||||
Week: sortByWPM(userStatistics),
|
Week: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.WeeklyWpm }),
|
||||||
},
|
},
|
||||||
Duration: LeaderboardData{
|
Duration: LeaderboardData{
|
||||||
All: sortByDuration(userStatistics),
|
All: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalSeconds }),
|
||||||
Year: sortByDuration(userStatistics),
|
Year: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlySeconds }),
|
||||||
Month: sortByDuration(userStatistics),
|
Month: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlySeconds }),
|
||||||
Week: sortByDuration(userStatistics),
|
Week: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklySeconds }),
|
||||||
},
|
},
|
||||||
Words: LeaderboardData{
|
Words: LeaderboardData{
|
||||||
All: sortByWords(userStatistics),
|
All: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalWordsRead }),
|
||||||
Year: sortByWords(userStatistics),
|
Year: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlyWordsRead }),
|
||||||
Month: sortByWords(userStatistics),
|
Month: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlyWordsRead }),
|
||||||
Week: sortByWords(userStatistics),
|
Week: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklyWordsRead }),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,8 +460,8 @@ components:
|
|||||||
user_id:
|
user_id:
|
||||||
type: string
|
type: string
|
||||||
value:
|
value:
|
||||||
type: integer
|
type: number
|
||||||
format: int64
|
format: double
|
||||||
required:
|
required:
|
||||||
- user_id
|
- user_id
|
||||||
- value
|
- value
|
||||||
@@ -617,6 +617,20 @@ components:
|
|||||||
filter:
|
filter:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
InfoResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
search_enabled:
|
||||||
|
type: boolean
|
||||||
|
registration_enabled:
|
||||||
|
type: boolean
|
||||||
|
required:
|
||||||
|
- version
|
||||||
|
- search_enabled
|
||||||
|
- registration_enabled
|
||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
type: http
|
type: http
|
||||||
@@ -1111,6 +1125,26 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/info:
|
||||||
|
get:
|
||||||
|
summary: Get server information
|
||||||
|
operationId: getInfo
|
||||||
|
tags:
|
||||||
|
- Info
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/InfoResponse'
|
||||||
|
500:
|
||||||
|
description: Internal server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
/home:
|
/home:
|
||||||
get:
|
get:
|
||||||
summary: Get home page data
|
summary: Get home page data
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
|
|||||||
ctx = context.WithValue(ctx, "request", r)
|
ctx = context.WithValue(ctx, "request", r)
|
||||||
ctx = context.WithValue(ctx, "response", w)
|
ctx = context.WithValue(ctx, "response", w)
|
||||||
|
|
||||||
// Skip auth for login endpoint only - cover and file require auth via cookies
|
// Skip auth for login and info endpoints - cover and file require auth via cookies
|
||||||
if operationID == "Login" {
|
if operationID == "Login" || operationID == "GetInfo" {
|
||||||
return handler(ctx, w, r, request)
|
return handler(ctx, w, r, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,3 +67,13 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInfo returns server information
|
||||||
|
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
|
||||||
|
return GetInfo200JSONResponse{
|
||||||
|
Version: s.cfg.Version,
|
||||||
|
SearchEnabled: s.cfg.SearchEnabled,
|
||||||
|
RegistrationEnabled: s.cfg.RegistrationEnabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
frontend/.prettierignore
Normal file
2
frontend/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Generated API code
|
||||||
|
src/generated/**/*
|
||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Home, FileText, Activity, Search, Settings } from 'lucide-react';
|
import { Home, FileText, Activity, Search, Settings } from 'lucide-react';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -36,6 +37,17 @@ export default function HamburgerMenu() {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const isAdmin = user?.is_admin ?? false;
|
const isAdmin = user?.is_admin ?? false;
|
||||||
|
|
||||||
|
// Fetch server info for version
|
||||||
|
const { data: infoData } = useGetInfo({
|
||||||
|
query: {
|
||||||
|
staleTime: Infinity, // Info doesn't change frequently
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const version =
|
||||||
|
infoData && 'data' in infoData && infoData.data && 'version' in infoData.data
|
||||||
|
? infoData.data.version
|
||||||
|
: 'v1.0.0';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-40 ml-6 flex flex-col">
|
<div className="relative z-40 ml-6 flex flex-col">
|
||||||
{/* Checkbox input for state management */}
|
{/* Checkbox input for state management */}
|
||||||
@@ -172,7 +184,48 @@ export default function HamburgerMenu() {
|
|||||||
href="https://gitea.va.reichard.io/evan/AnthoLume"
|
href="https://gitea.va.reichard.io/evan/AnthoLume"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
<span className="text-xs">v1.0.0</span>
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5 text-black dark:text-white"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 219 92"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="gitea_a">
|
||||||
|
<path d="M159 .79h25V69h-25Zm0 0" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="gitea_b">
|
||||||
|
<path d="M183 9h35.371v60H183Zm0 0" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="gitea_c">
|
||||||
|
<path d="M0 .79h92V92H0Zm0 0" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||||
|
d="M130.871 31.836c-4.785 0-8.351 2.352-8.351 8.008 0 4.261 2.347 7.222 8.093 7.222 4.871 0 8.18-2.867 8.18-7.398 0-5.133-2.961-7.832-7.922-7.832Zm-9.57 39.95c-1.133 1.39-2.262 2.87-2.262 4.612 0 3.48 4.434 4.524 10.527 4.524 5.051 0 11.926-.352 11.926-5.043 0-2.793-3.308-2.965-7.488-3.227Zm25.761-39.688c1.563 2.004 3.22 4.789 3.22 8.793 0 9.656-7.571 15.316-18.536 15.316-2.789 0-5.312-.348-6.879-.785l-2.87 4.613 8.526.52c15.059.96 23.934 1.398 23.934 12.968 0 10.008-8.789 15.665-23.934 15.665-15.75 0-21.757-4.004-21.757-10.88 0-3.917 1.742-6 4.789-8.878-2.875-1.211-3.828-3.387-3.828-5.739 0-1.914.953-3.656 2.523-5.312 1.566-1.652 3.305-3.305 5.395-5.219-4.262-2.09-7.485-6.617-7.485-13.058 0-10.008 6.613-16.88 19.93-16.88 3.742 0 6.004.344 8.008.872h16.972v7.394l-8.007.61"
|
||||||
|
/>
|
||||||
|
<g clipPath="url(#gitea_a)">
|
||||||
|
<path
|
||||||
|
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||||
|
d="M170.379 16.281c-4.961 0-7.832-2.87-7.832-7.836 0-4.957 2.871-7.656 7.832-7.656 5.05 0 7.922 2.7 7.922 7.656 0 4.965-2.871 7.836-7.922 7.836Zm-11.227 52.305V61.71l4.438-.606c1.219-.175 1.394-.437 1.394-1.746V33.773c0-.953-.261-1.566-1.132-1.824l-4.7-1.656.957-7.047h18.016V59.36c0 1.399.086 1.57 1.395 1.746l4.437.606v6.875h-24.805"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g clipPath="url(#gitea_b)">
|
||||||
|
<path
|
||||||
|
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||||
|
d="M218.371 65.21c-3.742 1.825-9.223 3.481-14.187 3.481-10.356 0-14.27-4.175-14.27-14.015V31.879c0-.524 0-.871-.7-.871h-6.093v-7.746c7.664-.871 10.707-4.703 11.664-14.188h8.27v12.36c0 .609 0 .87.695.87h12.27v8.704h-12.965v20.797c0 5.136 1.218 7.136 5.918 7.136 2.437 0 4.96-.609 7.047-1.39l2.351 7.66"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g clipPath="url(#gitea_c)">
|
||||||
|
<path
|
||||||
|
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||||
|
d="M89.422 42.371 49.629 2.582a5.868 5.868 0 0 0-8.3 0l-8.263 8.262 10.48 10.484a6.965 6.965 0 0 1 7.173 1.668 6.98 6.98 0 0 1 1.656 7.215l10.102 10.105a6.963 6.963 0 0 1 7.214 1.657 6.976 6.976 0 0 1 0 9.875 6.98 6.98 0 0 1-9.879 0 6.987 6.987 0 0 1-1.519-7.594l-9.422-9.422v24.793a6.979 6.979 0 0 1 1.848 1.32 6.988 6.988 0 0 1 0 9.88c-2.73 2.726-7.153 2.726-9.875 0a6.98 6.98 0 0 1 0-9.88 6.893 6.893 0 0 1 2.285-1.523V34.398a6.893 6.893 0 0 1-2.285-1.523 6.988 6.988 0 0 1-1.508-7.637L29.004 14.902 1.719 42.187a5.868 5.868 0 0 0 0 8.301l39.793 39.793a5.868 5.868 0 0 0 8.3 0l39.61-39.605a5.873 5.873 0 0 0 0-8.305"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs">{version}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
52
frontend/src/components/ReadingHistoryGraph.test.ts
Normal file
52
frontend/src/components/ReadingHistoryGraph.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { getSVGGraphData } from './ReadingHistoryGraph';
|
||||||
|
|
||||||
|
// Test data matching Go test exactly
|
||||||
|
const testInput = [
|
||||||
|
{ date: '2024-01-01', minutes_read: 10 },
|
||||||
|
{ date: '2024-01-02', minutes_read: 90 },
|
||||||
|
{ date: '2024-01-03', minutes_read: 50 },
|
||||||
|
{ date: '2024-01-04', minutes_read: 5 },
|
||||||
|
{ date: '2024-01-05', minutes_read: 10 },
|
||||||
|
{ date: '2024-01-06', minutes_read: 5 },
|
||||||
|
{ date: '2024-01-07', minutes_read: 70 },
|
||||||
|
{ date: '2024-01-08', minutes_read: 60 },
|
||||||
|
{ date: '2024-01-09', minutes_read: 50 },
|
||||||
|
{ date: '2024-01-10', minutes_read: 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const svgWidth = 500;
|
||||||
|
const svgHeight = 100;
|
||||||
|
|
||||||
|
describe('ReadingHistoryGraph', () => {
|
||||||
|
describe('getSVGGraphData', () => {
|
||||||
|
it('should match exactly', () => {
|
||||||
|
const result = getSVGGraphData(testInput, svgWidth, svgHeight);
|
||||||
|
|
||||||
|
// Expected values from Go test
|
||||||
|
const expectedBezierPath =
|
||||||
|
'M 50,95 C63,95 80,50 100,50 C120,50 128,73 150,73 C172,73 180,98 200,98 C220,98 230,95 250,95 C270,95 279,98 300,98 C321,98 330,62 350,62 C370,62 380,67 400,67 C420,67 430,73 450,73 C470,73 489,50 500,50';
|
||||||
|
const expectedBezierFill = 'L 500,98 L 50,98 Z';
|
||||||
|
const expectedWidth = 500;
|
||||||
|
const expectedHeight = 100;
|
||||||
|
const expectedOffset = 50;
|
||||||
|
|
||||||
|
expect(result.BezierPath).toBe(expectedBezierPath);
|
||||||
|
expect(result.BezierFill).toBe(expectedBezierFill);
|
||||||
|
expect(svgWidth).toBe(expectedWidth);
|
||||||
|
expect(svgHeight).toBe(expectedHeight);
|
||||||
|
expect(result.Offset).toBe(expectedOffset);
|
||||||
|
|
||||||
|
// Verify line points are integers like Go
|
||||||
|
result.LinePoints.forEach((p, _i) => {
|
||||||
|
expect(Number.isInteger(p.x)).toBe(true);
|
||||||
|
expect(Number.isInteger(p.y)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expected line points from Go calculation:
|
||||||
|
// idx 0: itemSize=5, itemY=95, lineX=50
|
||||||
|
// idx 1: itemSize=45, itemY=55, lineX=100
|
||||||
|
// idx 2: itemSize=25, itemY=75, lineX=150
|
||||||
|
// ...and so on
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,11 +2,9 @@ import type { GraphDataPoint } from '../generated/model';
|
|||||||
|
|
||||||
interface ReadingHistoryGraphProps {
|
interface ReadingHistoryGraphProps {
|
||||||
data: GraphDataPoint[];
|
data: GraphDataPoint[];
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SVGPoint {
|
export interface SVGPoint {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
@@ -14,6 +12,20 @@ interface SVGPoint {
|
|||||||
/**
|
/**
|
||||||
* Generates bezier control points for smooth curves
|
* Generates bezier control points for smooth curves
|
||||||
*/
|
*/
|
||||||
|
function getSVGBezierOpposedLine(
|
||||||
|
pointA: SVGPoint,
|
||||||
|
pointB: SVGPoint
|
||||||
|
): { Length: number; Angle: number } {
|
||||||
|
const lengthX = pointB.x - pointA.x;
|
||||||
|
const lengthY = pointB.y - pointA.y;
|
||||||
|
|
||||||
|
// Go uses int() which truncates toward zero, JavaScript Math.trunc matches this
|
||||||
|
return {
|
||||||
|
Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)),
|
||||||
|
Angle: Math.trunc(Math.atan2(lengthY, lengthX)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getBezierControlPoint(
|
function getBezierControlPoint(
|
||||||
currentPoint: SVGPoint,
|
currentPoint: SVGPoint,
|
||||||
prevPoint: SVGPoint | null,
|
prevPoint: SVGPoint | null,
|
||||||
@@ -21,92 +33,169 @@ function getBezierControlPoint(
|
|||||||
isReverse: boolean
|
isReverse: boolean
|
||||||
): SVGPoint {
|
): SVGPoint {
|
||||||
// First / Last Point
|
// First / Last Point
|
||||||
const pPrev = prevPoint || currentPoint;
|
let pPrev = prevPoint;
|
||||||
const pNext = nextPoint || currentPoint;
|
let pNext = nextPoint;
|
||||||
|
if (!pPrev) {
|
||||||
|
pPrev = currentPoint;
|
||||||
|
}
|
||||||
|
if (!pNext) {
|
||||||
|
pNext = currentPoint;
|
||||||
|
}
|
||||||
|
|
||||||
const smoothingRatio = 0.2;
|
// Modifiers
|
||||||
const directionModifier = isReverse ? Math.PI : 0;
|
const smoothingRatio: number = 0.2;
|
||||||
|
const directionModifier: number = isReverse ? Math.PI : 0;
|
||||||
|
|
||||||
const lengthX = pNext.x - pPrev.x;
|
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
|
||||||
const lengthY = pNext.y - pPrev.y;
|
const lineAngle: number = opposingLine.Angle + directionModifier;
|
||||||
|
const lineLength: number = opposingLine.Length * smoothingRatio;
|
||||||
const length = Math.sqrt(lengthX * lengthX + lengthY * lengthY);
|
|
||||||
const angle = Math.atan2(lengthY, lengthX) + directionModifier;
|
|
||||||
const controlPointLength = length * smoothingRatio;
|
|
||||||
|
|
||||||
|
// Calculate Control Point - Go converts everything to int
|
||||||
|
// Note: int(math.Cos(...) * lineLength) means truncate product, not truncate then multiply
|
||||||
return {
|
return {
|
||||||
x: currentPoint.x + Math.cos(angle) * controlPointLength,
|
x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
|
||||||
y: currentPoint.y + Math.sin(angle) * controlPointLength,
|
y: Math.floor(currentPoint.y + Math.trunc(Math.sin(lineAngle) * lineLength)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the bezier path for the graph
|
* Generates the bezier path for the graph
|
||||||
*/
|
*/
|
||||||
function generateBezierPath(points: SVGPoint[]): string {
|
function getSVGBezierPath(points: SVGPoint[]): string {
|
||||||
if (points.length === 0) return '';
|
if (points.length === 0) {
|
||||||
|
return '';
|
||||||
const first = points[0];
|
|
||||||
let path = `M ${first.x},${first.y}`;
|
|
||||||
|
|
||||||
for (let i = 1; i < points.length; i++) {
|
|
||||||
const current = points[i];
|
|
||||||
const prev = points[i - 1];
|
|
||||||
const prevPrev = i - 2 >= 0 ? points[i - 2] : current;
|
|
||||||
const next = i + 1 < points.length ? points[i + 1] : current;
|
|
||||||
|
|
||||||
const startControl = getBezierControlPoint(prev, prevPrev, current, false);
|
|
||||||
const endControl = getBezierControlPoint(current, prev, next, true);
|
|
||||||
|
|
||||||
path += ` C${startControl.x},${startControl.y} ${endControl.x},${endControl.y} ${current.x},${current.y}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return path;
|
let bezierSVGPath: string = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < points.length; index++) {
|
||||||
|
const point = points[index];
|
||||||
|
if (index === 0) {
|
||||||
|
bezierSVGPath += `M ${point.x},${point.y}`;
|
||||||
|
} else {
|
||||||
|
const pointPlusOne = points[index + 1];
|
||||||
|
const pointMinusOne = points[index - 1];
|
||||||
|
const pointMinusTwo: SVGPoint | null = index - 2 >= 0 ? points[index - 2] : null;
|
||||||
|
|
||||||
|
const startControlPoint: SVGPoint = getBezierControlPoint(
|
||||||
|
pointMinusOne,
|
||||||
|
pointMinusTwo,
|
||||||
|
point,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const endControlPoint: SVGPoint = getBezierControlPoint(
|
||||||
|
point,
|
||||||
|
pointMinusOne,
|
||||||
|
pointPlusOne || point,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Go converts all coordinates to int
|
||||||
|
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bezierSVGPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SVGGraphData {
|
||||||
|
LinePoints: SVGPoint[];
|
||||||
|
BezierPath: string;
|
||||||
|
BezierFill: string;
|
||||||
|
Offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate points for SVG rendering
|
* Get SVG Graph Data
|
||||||
*/
|
*/
|
||||||
function calculatePoints(
|
export function getSVGGraphData(
|
||||||
data: GraphDataPoint[],
|
inputData: GraphDataPoint[],
|
||||||
width: number,
|
svgWidth: number,
|
||||||
height: number
|
svgHeight: number
|
||||||
): SVGPoint[] {
|
): SVGGraphData {
|
||||||
if (data.length === 0) return [];
|
// Derive Height
|
||||||
|
let maxHeight: number = 0;
|
||||||
|
for (const item of inputData) {
|
||||||
|
if (item.minutes_read > maxHeight) {
|
||||||
|
maxHeight = item.minutes_read;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const maxMinutes = Math.max(...data.map((d) => d.minutes_read), 1);
|
// Vertical Graph Real Estate
|
||||||
const paddingX = width * 0.03; // 3% padding on sides
|
const sizePercentage: number = 0.5;
|
||||||
const paddingY = height * 0.1; // 10% padding on top/bottom
|
|
||||||
const usableWidth = width - paddingX * 2;
|
|
||||||
const usableHeight = height - paddingY * 2;
|
|
||||||
|
|
||||||
return data.map((point, index) => {
|
// Scale Ratio -> Desired Height
|
||||||
const x = paddingX + (index / (data.length - 1)) * usableWidth;
|
const sizeRatio: number = (svgHeight * sizePercentage) / maxHeight;
|
||||||
// Y is inverted (0 is top in SVG)
|
|
||||||
const y =
|
// Point Block Offset
|
||||||
paddingY + usableHeight - (point.minutes_read / maxMinutes) * usableHeight;
|
const blockOffset: number = Math.floor(svgWidth / inputData.length);
|
||||||
return { x, y };
|
|
||||||
|
// Line & Bar Points
|
||||||
|
const linePoints: SVGPoint[] = [];
|
||||||
|
|
||||||
|
// Bezier Fill Coordinates (Max X, Min X, Max Y)
|
||||||
|
let maxBX: number = 0;
|
||||||
|
let maxBY: number = 0;
|
||||||
|
let minBX: number = 0;
|
||||||
|
|
||||||
|
for (let idx = 0; idx < inputData.length; idx++) {
|
||||||
|
// Go uses int conversion
|
||||||
|
const itemSize = Math.floor(inputData[idx].minutes_read * sizeRatio);
|
||||||
|
const itemY = svgHeight - itemSize;
|
||||||
|
const lineX = (idx + 1) * blockOffset;
|
||||||
|
|
||||||
|
linePoints.push({
|
||||||
|
x: lineX,
|
||||||
|
y: itemY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (lineX > maxBX) {
|
||||||
|
maxBX = lineX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineX < minBX) {
|
||||||
|
minBX = lineX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemY > maxBY) {
|
||||||
|
maxBY = itemY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return Data
|
||||||
|
return {
|
||||||
|
LinePoints: linePoints,
|
||||||
|
BezierPath: getSVGBezierPath(linePoints),
|
||||||
|
BezierFill: `L ${Math.floor(maxBX)},${Math.floor(maxBY)} L ${Math.floor(minBX + blockOffset)},${Math.floor(maxBY)} Z`,
|
||||||
|
Offset: blockOffset,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a date string
|
* Formats a date string to YYYY-MM-DD format (ISO-like)
|
||||||
|
* Note: The date string from the API is already in YYYY-MM-DD format,
|
||||||
|
* but since JavaScript Date parsing can add timezone offsets, we use UTC
|
||||||
|
* methods to ensure we get the correct date.
|
||||||
*/
|
*/
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
// Use UTC methods to avoid timezone offset issues
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReadingHistoryGraph component
|
* ReadingHistoryGraph component
|
||||||
*
|
*
|
||||||
* Displays a bezier curve graph of daily reading totals with hover tooltips.
|
* Displays a bezier curve graph of daily reading totals with hover tooltips.
|
||||||
|
* Exact copy of Go template implementation.
|
||||||
*/
|
*/
|
||||||
export default function ReadingHistoryGraph({
|
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
|
||||||
data,
|
const svgWidth = 800;
|
||||||
width = 800,
|
const svgHeight = 70;
|
||||||
height = 70,
|
|
||||||
}: ReadingHistoryGraphProps) {
|
|
||||||
if (!data || data.length < 2) {
|
if (!data || data.length < 2) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600">
|
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600">
|
||||||
@@ -115,62 +204,49 @@ export default function ReadingHistoryGraph({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const points = calculatePoints(data, width, height);
|
const {
|
||||||
const bezierPath = generateBezierPath(points);
|
BezierPath,
|
||||||
|
BezierFill,
|
||||||
// Calculate fill path (closed loop for area fill)
|
LinePoints: _linePoints,
|
||||||
const firstX = Math.min(...points.map((p) => p.x));
|
} = getSVGGraphData(data, svgWidth, svgHeight);
|
||||||
const lastX = Math.max(...points.map((p) => p.x));
|
|
||||||
|
|
||||||
const areaPath = `${bezierPath} L ${lastX},${height} L ${firstX},${height} Z`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative">
|
||||||
<svg
|
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em">
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
<path fill="#316BBE" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
|
||||||
preserveAspectRatio="none"
|
<path fill="none" stroke="#316BBE" d={BezierPath} />
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
className="h-24"
|
|
||||||
>
|
|
||||||
{/* Area fill */}
|
|
||||||
<path
|
|
||||||
fill="#316BBE"
|
|
||||||
fillOpacity="0.5"
|
|
||||||
stroke="none"
|
|
||||||
d={areaPath}
|
|
||||||
/>
|
|
||||||
{/* Bezier curve line */}
|
|
||||||
<path
|
|
||||||
fill="none"
|
|
||||||
stroke="#316BBE"
|
|
||||||
strokeWidth="2"
|
|
||||||
d={bezierPath}
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
<div
|
||||||
{/* Hover overlays */}
|
className="absolute top-0 flex size-full"
|
||||||
<div className="absolute top-0 size-full">
|
style={{
|
||||||
{data.map((point, i) => {
|
width: 'calc(100% * 31 / 30)',
|
||||||
return (
|
transform: 'translateX(-50%)',
|
||||||
|
left: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.map((point, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="group relative flex-1 cursor-pointer"
|
onClick
|
||||||
onClick={(e) => e.preventDefault()}
|
className="w-full opacity-0 hover:opacity-100"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Vertical indicator line on hover */}
|
<div
|
||||||
<div className="absolute inset-0 flex items-center opacity-0 group-hover:opacity-100">
|
className="pointer-events-none absolute top-3 flex flex-col items-center rounded p-2 text-xs dark:text-white"
|
||||||
<div className="h-full w-px bg-gray-400 opacity-30" />
|
style={{
|
||||||
</div>
|
transform: 'translateX(-50%)',
|
||||||
|
left: '50%',
|
||||||
{/* Tooltip */}
|
backgroundColor: 'rgba(128, 128, 128, 0.2)',
|
||||||
<div className="pointer-events-none absolute bottom-full left-1/2 mb-2 hidden -translate-x-1/2 rounded-md bg-gray-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-gray-200 dark:text-gray-900">
|
}}
|
||||||
<div className="font-medium">{formatDate(point.date)}</div>
|
>
|
||||||
<div>{point.minutes_read} minutes</div>
|
<span>{formatDate(point.date)}</span>
|
||||||
|
<span>{point.minutes_read} minutes</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import type {
|
|||||||
GraphDataResponse,
|
GraphDataResponse,
|
||||||
HomeResponse,
|
HomeResponse,
|
||||||
ImportResultsResponse,
|
ImportResultsResponse,
|
||||||
|
InfoResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
LogsResponse,
|
LogsResponse,
|
||||||
@@ -1680,6 +1681,130 @@ export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = Err
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get server information
|
||||||
|
*/
|
||||||
|
export type getInfoResponse200 = {
|
||||||
|
data: InfoResponse
|
||||||
|
status: 200
|
||||||
|
}
|
||||||
|
|
||||||
|
export type getInfoResponse500 = {
|
||||||
|
data: ErrorResponse
|
||||||
|
status: 500
|
||||||
|
}
|
||||||
|
|
||||||
|
export type getInfoResponseSuccess = (getInfoResponse200) & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
export type getInfoResponseError = (getInfoResponse500) & {
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type getInfoResponse = (getInfoResponseSuccess | getInfoResponseError)
|
||||||
|
|
||||||
|
export const getGetInfoUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/v1/info`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getInfo = async ( options?: RequestInit): Promise<getInfoResponse> => {
|
||||||
|
|
||||||
|
const res = await fetch(getGetInfoUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||||
|
|
||||||
|
const data: getInfoResponse['data'] = body ? JSON.parse(body) : {}
|
||||||
|
return { data, status: res.status, headers: res.headers } as getInfoResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetInfoQueryKey = () => {
|
||||||
|
return [
|
||||||
|
`/api/v1/info`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetInfoQueryOptions = <TData = Awaited<ReturnType<typeof getInfo>>, TError = ErrorResponse>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData>>, fetch?: RequestInit}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, fetch: fetchOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetInfoQueryKey();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getInfo>>> = ({ signal }) => getInfo({ signal, ...fetchOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetInfoQueryResult = NonNullable<Awaited<ReturnType<typeof getInfo>>>
|
||||||
|
export type GetInfoQueryError = ErrorResponse
|
||||||
|
|
||||||
|
|
||||||
|
export function useGetInfo<TData = Awaited<ReturnType<typeof getInfo>>, TError = ErrorResponse>(
|
||||||
|
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData>> & Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof getInfo>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof getInfo>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, fetch?: RequestInit}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useGetInfo<TData = Awaited<ReturnType<typeof getInfo>>, TError = ErrorResponse>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData>> & Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof getInfo>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof getInfo>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, fetch?: RequestInit}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useGetInfo<TData = Awaited<ReturnType<typeof getInfo>>, TError = ErrorResponse>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData>>, fetch?: RequestInit}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
/**
|
||||||
|
* @summary Get server information
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetInfo<TData = Awaited<ReturnType<typeof getInfo>>, TError = ErrorResponse>(
|
||||||
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getInfo>>, TError, TData>>, fetch?: RequestInit}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
const queryOptions = getGetInfoQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get home page data
|
* @summary Get home page data
|
||||||
*/
|
*/
|
||||||
|
|||||||
13
frontend/src/generated/model/configResponse.ts
Normal file
13
frontend/src/generated/model/configResponse.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ConfigResponse {
|
||||||
|
version: string;
|
||||||
|
search_enabled: boolean;
|
||||||
|
registration_enabled: boolean;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
export * from './activity';
|
export * from './activity';
|
||||||
export * from './activityResponse';
|
export * from './activityResponse';
|
||||||
export * from './backupType';
|
export * from './backupType';
|
||||||
|
export * from './configResponse';
|
||||||
export * from './createDocumentBody';
|
export * from './createDocumentBody';
|
||||||
export * from './databaseInfo';
|
export * from './databaseInfo';
|
||||||
export * from './device';
|
export * from './device';
|
||||||
@@ -33,6 +34,7 @@ export * from './importResult';
|
|||||||
export * from './importResultsResponse';
|
export * from './importResultsResponse';
|
||||||
export * from './importResultStatus';
|
export * from './importResultStatus';
|
||||||
export * from './importType';
|
export * from './importType';
|
||||||
|
export * from './infoResponse';
|
||||||
export * from './leaderboardData';
|
export * from './leaderboardData';
|
||||||
export * from './leaderboardEntry';
|
export * from './leaderboardEntry';
|
||||||
export * from './logEntry';
|
export * from './logEntry';
|
||||||
|
|||||||
13
frontend/src/generated/model/infoResponse.ts
Normal file
13
frontend/src/generated/model/infoResponse.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* AnthoLume API v1
|
||||||
|
* REST API for AnthoLume document management system
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface InfoResponse {
|
||||||
|
version: string;
|
||||||
|
search_enabled: boolean;
|
||||||
|
registration_enabled: boolean;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||||
import { Table } from '../components/Table';
|
import { Table } from '../components/Table';
|
||||||
|
import { formatDuration } from '../utils/formatters';
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
|
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
|
||||||
@@ -28,18 +29,7 @@ export default function ActivityPage() {
|
|||||||
key: 'duration' as const,
|
key: 'duration' as const,
|
||||||
header: 'Duration',
|
header: 'Duration',
|
||||||
render: (value: any) => {
|
render: (value: any) => {
|
||||||
if (!value) return 'N/A';
|
return formatDuration(value || 0);
|
||||||
// Format duration (in seconds) to readable format
|
|
||||||
const hours = Math.floor(value / 3600);
|
|
||||||
const minutes = Math.floor((value % 3600) / 60);
|
|
||||||
const seconds = value % 60;
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m ${seconds}s`;
|
|
||||||
} else if (minutes > 0) {
|
|
||||||
return `${minutes}m ${seconds}s`;
|
|
||||||
} else {
|
|
||||||
return `${seconds}s`;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
||||||
|
import { formatDuration, formatNumber } from '../utils/formatters';
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,26 +31,6 @@ interface Progress {
|
|||||||
author?: string;
|
author?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to format seconds nicely (mirroring legacy niceSeconds)
|
|
||||||
function niceSeconds(seconds: number): string {
|
|
||||||
if (seconds === 0) return 'N/A';
|
|
||||||
|
|
||||||
const days = Math.floor(seconds / 60 / 60 / 24);
|
|
||||||
const remainingSeconds = seconds % (60 * 60 * 24);
|
|
||||||
const hours = Math.floor(remainingSeconds / 60 / 60);
|
|
||||||
const remainingAfterHours = remainingSeconds % (60 * 60);
|
|
||||||
const minutes = Math.floor(remainingAfterHours / 60);
|
|
||||||
const remainingSeconds2 = remainingAfterHours % 60;
|
|
||||||
|
|
||||||
let result = '';
|
|
||||||
if (days > 0) result += `${days}d `;
|
|
||||||
if (hours > 0) result += `${hours}h `;
|
|
||||||
if (minutes > 0) result += `${minutes}m `;
|
|
||||||
if (remainingSeconds2 > 0) result += `${remainingSeconds2}s`;
|
|
||||||
|
|
||||||
return result || 'N/A';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DocumentPage() {
|
export default function DocumentPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
||||||
@@ -163,7 +144,7 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<p className="text-lg font-medium">
|
<p className="text-lg font-medium">
|
||||||
{document.total_time_seconds ? niceSeconds(document.total_time_seconds) : 'N/A'}
|
{document.total_time_seconds ? formatDuration(document.total_time_seconds) : 'N/A'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +172,9 @@ export default function DocumentPage() {
|
|||||||
<div className="mt-4 grid gap-4 sm:grid-cols-3">
|
<div className="mt-4 grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500">Words</p>
|
<p className="text-gray-500">Words</p>
|
||||||
<p className="font-medium">{document.words || 'N/A'}</p>
|
<p className="font-medium">
|
||||||
|
{document.words != null ? formatNumber(document.words) : 'N/A'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500">Created</p>
|
<p className="text-gray-500">Created</p>
|
||||||
@@ -212,7 +195,9 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-gray-500">Est. Time Left:</p>
|
<p className="text-gray-500">Est. Time Left:</p>
|
||||||
<p className="whitespace-nowrap font-medium">{niceSeconds(totalTimeLeftSeconds)}</p>
|
<p className="whitespace-nowrap font-medium">
|
||||||
|
{formatDuration(totalTimeLeftSeconds)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'
|
|||||||
import { Activity, Download, Search, Upload } from 'lucide-react';
|
import { Activity, Download, Search, Upload } from 'lucide-react';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
import { formatDuration } from '../utils/formatters';
|
||||||
|
|
||||||
interface DocumentCardProps {
|
interface DocumentCardProps {
|
||||||
doc: {
|
doc: {
|
||||||
@@ -23,16 +24,6 @@ function DocumentCard({ doc }: DocumentCardProps) {
|
|||||||
const percentage = doc.percentage || 0;
|
const percentage = doc.percentage || 0;
|
||||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||||
|
|
||||||
// Convert seconds to nice format (e.g., "2h 30m")
|
|
||||||
const niceSeconds = (seconds: number): string => {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m`;
|
|
||||||
}
|
|
||||||
return `${minutes}m`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<div className="flex size-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
<div className="flex size-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
||||||
@@ -67,7 +58,7 @@ function DocumentCard({ doc }: DocumentCardProps) {
|
|||||||
<div className="inline-flex shrink-0 items-center">
|
<div className="inline-flex shrink-0 items-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-400">Time Read</p>
|
<p className="text-gray-400">Time Read</p>
|
||||||
<p className="font-medium">{niceSeconds(totalTimeSeconds)}</p>
|
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
|
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
|
||||||
import type { LeaderboardData } from '../generated/model';
|
import type { LeaderboardData } from '../generated/model';
|
||||||
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
||||||
|
import { formatNumber, formatDuration } from '../utils/formatters';
|
||||||
|
|
||||||
interface InfoCardProps {
|
interface InfoCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -93,7 +95,30 @@ interface LeaderboardCardProps {
|
|||||||
data: LeaderboardData;
|
data: LeaderboardData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimePeriod = 'all' | 'year' | 'month' | 'week';
|
||||||
|
|
||||||
function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>('all');
|
||||||
|
|
||||||
|
const formatValue = (value: number): string => {
|
||||||
|
switch (name) {
|
||||||
|
case 'WPM':
|
||||||
|
return `${value.toFixed(2)} WPM`;
|
||||||
|
case 'Duration':
|
||||||
|
return formatDuration(value);
|
||||||
|
case 'Words':
|
||||||
|
return formatNumber(value);
|
||||||
|
default:
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentData = data[selectedPeriod];
|
||||||
|
|
||||||
|
const handlePeriodChange = (period: TimePeriod) => {
|
||||||
|
setSelectedPeriod(period);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex size-full flex-col justify-between rounded bg-white px-4 py-6 shadow-lg dark:bg-gray-700">
|
<div className="flex size-full flex-col justify-between rounded bg-white px-4 py-6 shadow-lg dark:bg-gray-700">
|
||||||
@@ -102,28 +127,52 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
<p className="w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
<p className="w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
||||||
{name} Leaderboard
|
{name} Leaderboard
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
<div className="flex gap-2 text-xs text-gray-400 items-center">
|
||||||
<span className="cursor-pointer hover:text-black dark:hover:text-white">all</span>
|
<button
|
||||||
<span className="cursor-pointer hover:text-black dark:hover:text-white">year</span>
|
type="button"
|
||||||
<span className="cursor-pointer hover:text-black dark:hover:text-white">month</span>
|
onClick={() => handlePeriodChange('all')}
|
||||||
<span className="cursor-pointer hover:text-black dark:hover:text-white">week</span>
|
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'all' ? '!text-black dark:!text-white' : ''}`}
|
||||||
|
>
|
||||||
|
all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePeriodChange('year')}
|
||||||
|
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'year' ? '!text-black dark:!text-white' : ''}`}
|
||||||
|
>
|
||||||
|
year
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePeriodChange('month')}
|
||||||
|
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'month' ? '!text-black dark:!text-white' : ''}`}
|
||||||
|
>
|
||||||
|
month
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePeriodChange('week')}
|
||||||
|
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'week' ? '!text-black dark:!text-white' : ''}`}
|
||||||
|
>
|
||||||
|
week
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* All time data */}
|
{/* Current period data */}
|
||||||
<div className="my-6 flex items-end space-x-2">
|
<div className="my-6 flex items-end space-x-2">
|
||||||
{data.all.length === 0 ? (
|
{currentData?.length === 0 ? (
|
||||||
<p className="text-5xl font-bold text-black dark:text-white">N/A</p>
|
<p className="text-5xl font-bold text-black dark:text-white">N/A</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-5xl font-bold text-black dark:text-white">
|
<p className="text-5xl font-bold text-black dark:text-white">
|
||||||
{data.all[0]?.user_id || 'N/A'}
|
{currentData[0]?.user_id || 'N/A'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dark:text-white">
|
<div className="dark:text-white">
|
||||||
{data.all.slice(0, 3).map((item: any, index: number) => (
|
{currentData?.slice(0, 3).map((item: any, index: number) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`}
|
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`}
|
||||||
@@ -131,7 +180,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
<div>
|
<div>
|
||||||
<p>{item.user_id}</p>
|
<p>{item.user_id}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end font-bold">{item.value}</div>
|
<div className="flex items-end font-bold">{formatValue(item.value)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -140,8 +189,6 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||||
const { data: docsData, isLoading: docsLoading } = useGetDocuments({ page: 1, limit: 9 });
|
const { data: docsData, isLoading: docsLoading } = useGetDocuments({ page: 1, limit: 9 });
|
||||||
|
|||||||
76
frontend/src/utils/formatters.test.ts
Normal file
76
frontend/src/utils/formatters.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatNumber, formatDuration } from './formatters';
|
||||||
|
|
||||||
|
describe('formatNumber', () => {
|
||||||
|
it('formats zero', () => {
|
||||||
|
expect(formatNumber(0)).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats small numbers', () => {
|
||||||
|
expect(formatNumber(5)).toBe('5.00');
|
||||||
|
expect(formatNumber(15)).toBe('15.0');
|
||||||
|
expect(formatNumber(99)).toBe('99.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats thousands', () => {
|
||||||
|
expect(formatNumber(19823)).toBe('19.8k');
|
||||||
|
expect(formatNumber(1984)).toBe('1.98k');
|
||||||
|
expect(formatNumber(1000)).toBe('1.00k');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats millions', () => {
|
||||||
|
expect(formatNumber(1500000)).toBe('1.50M');
|
||||||
|
expect(formatNumber(198236461)).toBe('198M');
|
||||||
|
expect(formatNumber(1000000)).toBe('1.00M');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats large numbers', () => {
|
||||||
|
expect(formatNumber(1500000000)).toBe('1.50B');
|
||||||
|
expect(formatNumber(1500000000000)).toBe('1.50T');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats negative numbers', () => {
|
||||||
|
expect(formatNumber(-12345)).toBe('-12.3k');
|
||||||
|
expect(formatNumber(-1500000)).toBe('-1.50M');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches Go test cases exactly', () => {
|
||||||
|
expect(formatNumber(0)).toBe('0');
|
||||||
|
expect(formatNumber(19823)).toBe('19.8k');
|
||||||
|
expect(formatNumber(1500000)).toBe('1.50M');
|
||||||
|
expect(formatNumber(-12345)).toBe('-12.3k');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDuration', () => {
|
||||||
|
it('formats zero as N/A', () => {
|
||||||
|
expect(formatDuration(0)).toBe('N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats seconds only', () => {
|
||||||
|
expect(formatDuration(5)).toBe('5s');
|
||||||
|
expect(formatDuration(15)).toBe('15s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats minutes and seconds', () => {
|
||||||
|
expect(formatDuration(60)).toBe('1m');
|
||||||
|
expect(formatDuration(75)).toBe('1m 15s');
|
||||||
|
expect(formatDuration(315)).toBe('5m 15s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats hours, minutes, and seconds', () => {
|
||||||
|
expect(formatDuration(3600)).toBe('1h');
|
||||||
|
expect(formatDuration(3665)).toBe('1h 1m 5s');
|
||||||
|
expect(formatDuration(3915)).toBe('1h 5m 15s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats days, hours, minutes, and seconds', () => {
|
||||||
|
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches Go test cases exactly', () => {
|
||||||
|
expect(formatDuration(0)).toBe('N/A');
|
||||||
|
expect(formatDuration(22 * 24 * 60 * 60 + 7 * 60 * 60 + 39 * 60 + 31)).toBe('22d 7h 39m 31s');
|
||||||
|
expect(formatDuration(5 * 60 + 15)).toBe('5m 15s');
|
||||||
|
});
|
||||||
|
});
|
||||||
72
frontend/src/utils/formatters.ts
Normal file
72
frontend/src/utils/formatters.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* FormatNumber takes a number and returns a human-readable string.
|
||||||
|
* For example: 19823 -> "19.8k", 1500000 -> "1.50M"
|
||||||
|
*/
|
||||||
|
export function formatNumber(input: number): string {
|
||||||
|
if (input === 0) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle negative numbers
|
||||||
|
const negative = input < 0;
|
||||||
|
if (negative) {
|
||||||
|
input = -input;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abbreviations = ['', 'k', 'M', 'B', 'T'];
|
||||||
|
const abbrevIndex = Math.floor(Math.log10(input) / 3);
|
||||||
|
|
||||||
|
// Bounds check
|
||||||
|
const safeIndex = Math.min(abbrevIndex, abbreviations.length - 1);
|
||||||
|
|
||||||
|
const scaledNumber = input / Math.pow(10, safeIndex * 3);
|
||||||
|
|
||||||
|
let result: string;
|
||||||
|
if (scaledNumber >= 100) {
|
||||||
|
result = `${Math.round(scaledNumber)}${abbreviations[safeIndex]}`;
|
||||||
|
} else if (scaledNumber >= 10) {
|
||||||
|
result = `${scaledNumber.toFixed(1)}${abbreviations[safeIndex]}`;
|
||||||
|
} else {
|
||||||
|
result = `${scaledNumber.toFixed(2)}${abbreviations[safeIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (negative) {
|
||||||
|
result = `-${result}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FormatDuration takes duration in seconds and returns a human-readable string.
|
||||||
|
* For example: 1928371 seconds -> "22d 7h 39m 31s"
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
if (seconds === 0) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
const days = Math.floor(seconds / (60 * 60 * 24));
|
||||||
|
seconds %= 60 * 60 * 24;
|
||||||
|
const hours = Math.floor(seconds / (60 * 60));
|
||||||
|
seconds %= 60 * 60;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
seconds %= 60;
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
parts.push(`${days}d`);
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
parts.push(`${hours}h`);
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
parts.push(`${minutes}m`);
|
||||||
|
}
|
||||||
|
if (seconds > 0) {
|
||||||
|
parts.push(`${seconds}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user