diff --git a/AGENTS.md b/AGENTS.md
index 634c3f3..9fd02b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,6 +5,14 @@ Updating Go templates (rendered HTML) → React app using V1 API (OpenAPI spec)
## 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
- **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`
diff --git a/api/v1/api.gen.go b/api/v1/api.gen.go
index 202a1a7..0c4fe34 100644
--- a/api/v1/api.gen.go
+++ b/api/v1/api.gen.go
@@ -280,6 +280,13 @@ type ImportResultsResponse struct {
// ImportType defines model for ImportType.
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.
type LeaderboardData struct {
All []LeaderboardEntry `json:"all"`
@@ -290,8 +297,8 @@ type LeaderboardData struct {
// LeaderboardEntry defines model for LeaderboardEntry.
type LeaderboardEntry struct {
- UserId string `json:"user_id"`
- Value int64 `json:"value"`
+ UserId string `json:"user_id"`
+ Value float64 `json:"value"`
}
// LogEntry defines model for LogEntry.
@@ -598,6 +605,9 @@ type ServerInterface interface {
// Get user streaks
// (GET /home/streaks)
GetStreaks(w http.ResponseWriter, r *http.Request)
+ // Get server information
+ // (GET /info)
+ GetInfo(w http.ResponseWriter, r *http.Request)
// List progress records
// (GET /progress)
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)
}
+// 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
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/statistics", wrapper.GetUserStatistics)
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/{id}", wrapper.GetProgress)
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)
}
+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 {
Params GetProgressListParams
}
@@ -2650,6 +2700,9 @@ type StrictServerInterface interface {
// Get user streaks
// (GET /home/streaks)
GetStreaks(ctx context.Context, request GetStreaksRequestObject) (GetStreaksResponseObject, error)
+ // Get server information
+ // (GET /info)
+ GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error)
// List progress records
// (GET /progress)
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
func (sh *strictHandler) GetProgressList(w http.ResponseWriter, r *http.Request, params GetProgressListParams) {
var request GetProgressListRequestObject
diff --git a/api/v1/home.go b/api/v1/home.go
index 7ccd565..4d86504 100644
--- a/api/v1/home.go
+++ b/api/v1/home.go
@@ -174,66 +174,66 @@ func convertGraphData(graphData []database.GetDailyReadStatsRow) []GraphDataPoin
}
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) UserStatisticsResponse {
- // Sort helper - sort by WPM
- sortByWPM := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
+ // Sort by WPM for each period
+ sortByWPM := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) float64) []LeaderboardEntry {
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
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))
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
}
- // Sort by duration (seconds)
-sortByDuration := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
+ // Sort by duration (seconds) for each period
+ sortByDuration := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
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))
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
}
- // Sort by words
-sortByWords := func(stats []database.GetUserStatisticsRow) []LeaderboardEntry {
+ // Sort by words for each period
+ sortByWords := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
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))
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 UserStatisticsResponse{
Wpm: LeaderboardData{
- All: sortByWPM(userStatistics),
- Year: sortByWPM(userStatistics),
- Month: sortByWPM(userStatistics),
- Week: sortByWPM(userStatistics),
+ All: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.TotalWpm }),
+ Year: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.YearlyWpm }),
+ Month: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.MonthlyWpm }),
+ Week: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.WeeklyWpm }),
},
Duration: LeaderboardData{
- All: sortByDuration(userStatistics),
- Year: sortByDuration(userStatistics),
- Month: sortByDuration(userStatistics),
- Week: sortByDuration(userStatistics),
+ All: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalSeconds }),
+ Year: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlySeconds }),
+ Month: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlySeconds }),
+ Week: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklySeconds }),
},
Words: LeaderboardData{
- All: sortByWords(userStatistics),
- Year: sortByWords(userStatistics),
- Month: sortByWords(userStatistics),
- Week: sortByWords(userStatistics),
+ All: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalWordsRead }),
+ Year: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlyWordsRead }),
+ Month: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlyWordsRead }),
+ Week: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklyWordsRead }),
},
}
}
diff --git a/api/v1/openapi.yaml b/api/v1/openapi.yaml
index da79ca1..e15353a 100644
--- a/api/v1/openapi.yaml
+++ b/api/v1/openapi.yaml
@@ -460,8 +460,8 @@ components:
user_id:
type: string
value:
- type: integer
- format: int64
+ type: number
+ format: double
required:
- user_id
- value
@@ -617,6 +617,20 @@ components:
filter:
type: string
+ InfoResponse:
+ type: object
+ properties:
+ version:
+ type: string
+ search_enabled:
+ type: boolean
+ registration_enabled:
+ type: boolean
+ required:
+ - version
+ - search_enabled
+ - registration_enabled
+
securitySchemes:
BearerAuth:
type: http
@@ -1111,6 +1125,26 @@ paths:
schema:
$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:
get:
summary: Get home page data
diff --git a/api/v1/server.go b/api/v1/server.go
index f6f94a4..830970c 100644
--- a/api/v1/server.go
+++ b/api/v1/server.go
@@ -46,8 +46,8 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
ctx = context.WithValue(ctx, "request", r)
ctx = context.WithValue(ctx, "response", w)
- // Skip auth for login endpoint only - cover and file require auth via cookies
- if operationID == "Login" {
+ // Skip auth for login and info endpoints - cover and file require auth via cookies
+ if operationID == "Login" || operationID == "GetInfo" {
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
+}
+
+
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
new file mode 100644
index 0000000..bf2437f
--- /dev/null
+++ b/frontend/.prettierignore
@@ -0,0 +1,2 @@
+# Generated API code
+src/generated/**/*
diff --git a/frontend/src/components/HamburgerMenu.tsx b/frontend/src/components/HamburgerMenu.tsx
index 84bf0a0..fd5fe22 100644
--- a/frontend/src/components/HamburgerMenu.tsx
+++ b/frontend/src/components/HamburgerMenu.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Home, FileText, Activity, Search, Settings } from 'lucide-react';
import { useAuth } from '../auth/AuthContext';
+import { useGetInfo } from '../generated/anthoLumeAPIV1';
interface NavItem {
path: string;
@@ -36,6 +37,17 @@ export default function HamburgerMenu() {
const [isOpen, setIsOpen] = useState(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 (
{/* Checkbox input for state management */}
@@ -172,7 +184,48 @@ export default function HamburgerMenu() {
href="https://gitea.va.reichard.io/evan/AnthoLume"
rel="noreferrer"
>
-
v1.0.0
+
+
{version}
diff --git a/frontend/src/components/ReadingHistoryGraph.test.ts b/frontend/src/components/ReadingHistoryGraph.test.ts
new file mode 100644
index 0000000..35fdd65
--- /dev/null
+++ b/frontend/src/components/ReadingHistoryGraph.test.ts
@@ -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
+ });
+ });
+});
diff --git a/frontend/src/components/ReadingHistoryGraph.tsx b/frontend/src/components/ReadingHistoryGraph.tsx
index 6e884b3..8ff3333 100644
--- a/frontend/src/components/ReadingHistoryGraph.tsx
+++ b/frontend/src/components/ReadingHistoryGraph.tsx
@@ -2,11 +2,9 @@ import type { GraphDataPoint } from '../generated/model';
interface ReadingHistoryGraphProps {
data: GraphDataPoint[];
- width?: number;
- height?: number;
}
-interface SVGPoint {
+export interface SVGPoint {
x: number;
y: number;
}
@@ -14,6 +12,20 @@ interface SVGPoint {
/**
* 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(
currentPoint: SVGPoint,
prevPoint: SVGPoint | null,
@@ -21,92 +33,169 @@ function getBezierControlPoint(
isReverse: boolean
): SVGPoint {
// First / Last Point
- const pPrev = prevPoint || currentPoint;
- const pNext = nextPoint || currentPoint;
+ let pPrev = prevPoint;
+ let pNext = nextPoint;
+ if (!pPrev) {
+ pPrev = currentPoint;
+ }
+ if (!pNext) {
+ pNext = currentPoint;
+ }
- const smoothingRatio = 0.2;
- const directionModifier = isReverse ? Math.PI : 0;
+ // Modifiers
+ const smoothingRatio: number = 0.2;
+ const directionModifier: number = isReverse ? Math.PI : 0;
- const lengthX = pNext.x - pPrev.x;
- const lengthY = pNext.y - pPrev.y;
-
- const length = Math.sqrt(lengthX * lengthX + lengthY * lengthY);
- const angle = Math.atan2(lengthY, lengthX) + directionModifier;
- const controlPointLength = length * smoothingRatio;
+ const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
+ const lineAngle: number = opposingLine.Angle + directionModifier;
+ const lineLength: number = opposingLine.Length * smoothingRatio;
+ // Calculate Control Point - Go converts everything to int
+ // Note: int(math.Cos(...) * lineLength) means truncate product, not truncate then multiply
return {
- x: currentPoint.x + Math.cos(angle) * controlPointLength,
- y: currentPoint.y + Math.sin(angle) * controlPointLength,
+ x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
+ y: Math.floor(currentPoint.y + Math.trunc(Math.sin(lineAngle) * lineLength)),
};
}
/**
* Generates the bezier path for the graph
*/
-function generateBezierPath(points: SVGPoint[]): string {
- 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}`;
+function getSVGBezierPath(points: SVGPoint[]): string {
+ if (points.length === 0) {
+ return '';
}
- 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(
- data: GraphDataPoint[],
- width: number,
- height: number
-): SVGPoint[] {
- if (data.length === 0) return [];
+export function getSVGGraphData(
+ inputData: GraphDataPoint[],
+ svgWidth: number,
+ svgHeight: number
+): SVGGraphData {
+ // 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);
- const paddingX = width * 0.03; // 3% padding on sides
- const paddingY = height * 0.1; // 10% padding on top/bottom
- const usableWidth = width - paddingX * 2;
- const usableHeight = height - paddingY * 2;
+ // Vertical Graph Real Estate
+ const sizePercentage: number = 0.5;
- return data.map((point, index) => {
- const x = paddingX + (index / (data.length - 1)) * usableWidth;
- // Y is inverted (0 is top in SVG)
- const y =
- paddingY + usableHeight - (point.minutes_read / maxMinutes) * usableHeight;
- return { x, y };
- });
+ // Scale Ratio -> Desired Height
+ const sizeRatio: number = (svgHeight * sizePercentage) / maxHeight;
+
+ // Point Block Offset
+ const blockOffset: number = Math.floor(svgWidth / inputData.length);
+
+ // 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 {
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
- *
+ *
* Displays a bezier curve graph of daily reading totals with hover tooltips.
+ * Exact copy of Go template implementation.
*/
-export default function ReadingHistoryGraph({
- data,
- width = 800,
- height = 70,
-}: ReadingHistoryGraphProps) {
+export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
+ const svgWidth = 800;
+ const svgHeight = 70;
+
if (!data || data.length < 2) {
return (
@@ -115,62 +204,49 @@ export default function ReadingHistoryGraph({
);
}
- const points = calculatePoints(data, width, height);
- const bezierPath = generateBezierPath(points);
-
- // Calculate fill path (closed loop for area fill)
- const firstX = Math.min(...points.map((p) => p.x));
- const lastX = Math.max(...points.map((p) => p.x));
-
- const areaPath = `${bezierPath} L ${lastX},${height} L ${firstX},${height} Z`;
+ const {
+ BezierPath,
+ BezierFill,
+ LinePoints: _linePoints,
+ } = getSVGGraphData(data, svgWidth, svgHeight);
return (
-
-
@@ -191,7 +172,9 @@ export default function DocumentPage() {
Words
-
{document.words || 'N/A'}
+
+ {document.words != null ? formatNumber(document.words) : 'N/A'}
+
Created
@@ -212,7 +195,9 @@ export default function DocumentPage() {
Est. Time Left:
-
{niceSeconds(totalTimeLeftSeconds)}
+
+ {formatDuration(totalTimeLeftSeconds)}
+
)}
diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx
index b02fee5..083a027 100644
--- a/frontend/src/pages/DocumentsPage.tsx
+++ b/frontend/src/pages/DocumentsPage.tsx
@@ -4,6 +4,7 @@ import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'
import { Activity, Download, Search, Upload } from 'lucide-react';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
+import { formatDuration } from '../utils/formatters';
interface DocumentCardProps {
doc: {
@@ -23,16 +24,6 @@ function DocumentCard({ doc }: DocumentCardProps) {
const percentage = doc.percentage || 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 (
@@ -67,7 +58,7 @@ function DocumentCard({ doc }: DocumentCardProps) {
Time Read
-
{niceSeconds(totalTimeSeconds)}
+
{formatDuration(totalTimeSeconds)}
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index 866b94e..50a1857 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -1,7 +1,9 @@
+import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
import type { LeaderboardData } from '../generated/model';
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
+import { formatNumber, formatDuration } from '../utils/formatters';
interface InfoCardProps {
title: string;
@@ -93,7 +95,30 @@ interface LeaderboardCardProps {
data: LeaderboardData;
}
+type TimePeriod = 'all' | 'year' | 'month' | 'week';
+
function LeaderboardCard({ name, data }: LeaderboardCardProps) {
+ const [selectedPeriod, setSelectedPeriod] = useState
('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 (
@@ -102,28 +127,52 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
{name} Leaderboard
-
-
all
-
year
-
month
-
week
+
+
+
+
+
- {/* All time data */}
+ {/* Current period data */}
- {data.all.length === 0 ? (
+ {currentData?.length === 0 ? (
N/A
) : (
- {data.all[0]?.user_id || 'N/A'}
+ {currentData[0]?.user_id || 'N/A'}
)}
- {data.all.slice(0, 3).map((item: any, index: number) => (
+ {currentData?.slice(0, 3).map((item: any, index: number) => (
0 ? 'border-t border-gray-200' : ''}`}
@@ -131,7 +180,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
-
{item.value}
+
{formatValue(item.value)}
))}
@@ -140,8 +189,6 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
);
}
-
-
export default function HomePage() {
const { data: homeData, isLoading: homeLoading } = useGetHome();
const { data: docsData, isLoading: docsLoading } = useGetDocuments({ page: 1, limit: 9 });
diff --git a/frontend/src/utils/formatters.test.ts b/frontend/src/utils/formatters.test.ts
new file mode 100644
index 0000000..e17ada5
--- /dev/null
+++ b/frontend/src/utils/formatters.test.ts
@@ -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');
+ });
+});
diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts
new file mode 100644
index 0000000..4b1bf27
--- /dev/null
+++ b/frontend/src/utils/formatters.ts
@@ -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(' ');
+}