Fix date inconsistencies in Favorite Days and Reading Session Timeline charts (#2096)

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2026-01-01 17:34:33 -07:00
committed by GitHub
parent c7cfe266d4
commit 6394d1ef04
8 changed files with 92 additions and 90 deletions
@@ -34,16 +34,15 @@ public class UserStatsController {
@Operation(summary = "Get reading session timeline for a week", description = "Returns reading sessions grouped by book for calendar timeline view")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Timeline data retrieved successfully"),
@ApiResponse(responseCode = "400", description = "Invalid week, month, or year"),
@ApiResponse(responseCode = "400", description = "Invalid week or year"),
@ApiResponse(responseCode = "401", description = "Unauthorized")
})
@GetMapping("/timeline")
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
public ResponseEntity<List<ReadingSessionTimelineResponse>> getTimelineForWeek(
@RequestParam int year,
@RequestParam int month,
@RequestParam int week) {
List<ReadingSessionTimelineResponse> timelineData = readingSessionService.getSessionTimelineForWeek(year, month, week);
List<ReadingSessionTimelineResponse> timelineData = readingSessionService.getSessionTimelineForWeek(year, week);
return ResponseEntity.ok(timelineData);
}
@@ -111,4 +110,3 @@ public class UserStatsController {
return ResponseEntity.ok(timeline);
}
}
@@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
@Repository
@@ -36,17 +37,14 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
FROM ReadingSessionEntity rs
JOIN rs.book b
WHERE rs.user.id = :userId
AND YEAR(rs.startTime) = :year
AND MONTH(rs.startTime) = :month
AND WEEK(rs.startTime) = :week
AND rs.startTime >= :startOfWeek AND rs.startTime < :endOfWeek
GROUP BY b.id, b.metadata.title, rs.bookType
ORDER BY MIN(rs.startTime)
""")
List<ReadingSessionTimelineDto> findSessionTimelineByUserAndWeek(
@Param("userId") Long userId,
@Param("year") int year,
@Param("month") int month,
@Param("week") int week);
@Param("startOfWeek") Instant startOfWeek,
@Param("endOfWeek") Instant endOfWeek);
@Query("""
SELECT
@@ -29,6 +29,12 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
import java.time.temporal.WeekFields;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
@@ -88,11 +94,16 @@ public class ReadingSessionService {
}
@Transactional(readOnly = true)
public List<ReadingSessionTimelineResponse> getSessionTimelineForWeek(int year, int month, int week) {
public List<ReadingSessionTimelineResponse> getSessionTimelineForWeek(int year, int week) {
BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser();
Long userId = authenticatedUser.getId();
return readingSessionRepository.findSessionTimelineByUserAndWeek(userId, year, month, week)
LocalDate date = LocalDate.of(year, 1, 1)
.with(WeekFields.of(DayOfWeek.MONDAY, 1).weekOfYear(), week);
LocalDateTime startOfWeek = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
LocalDateTime endOfWeek = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).plusDays(1).atStartOfDay();
return readingSessionRepository.findSessionTimelineByUserAndWeek(userId, startOfWeek.atZone(ZoneId.systemDefault()).toInstant(), endOfWeek.atZone(ZoneId.systemDefault()).toInstant())
.stream()
.map(dto -> ReadingSessionTimelineResponse.builder()
.bookId(dto.getBookId())
+11
View File
@@ -26,6 +26,7 @@
"chart.js": "^4.5.1",
"chartjs-chart-matrix": "^3.0.0",
"chartjs-plugin-datalabels": "^2.2.0",
"date-fns": "^4.1.0",
"epubjs": "^0.3.93",
"jwt-decode": "^4.0.0",
"ng-lazyload-image": "^9.1.3",
@@ -5852,6 +5853,16 @@
"node": ">=0.12"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-format": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
+2 -1
View File
@@ -30,6 +30,7 @@
"chart.js": "^4.5.1",
"chartjs-chart-matrix": "^3.0.0",
"chartjs-plugin-datalabels": "^2.2.0",
"date-fns": "^4.1.0",
"epubjs": "^0.3.93",
"jwt-decode": "^4.0.0",
"ng-lazyload-image": "^9.1.3",
@@ -66,4 +67,4 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.50.0"
}
}
}
@@ -59,14 +59,14 @@ export class UserStatsService {
getHeatmapForYear(year: number): Observable<ReadingSessionHeatmapResponse[]> {
return this.http.get<ReadingSessionHeatmapResponse[]>(
`${this.readingSessionsUrl}/heatmap`,
{ params: { year: year.toString() } }
{params: {year: year.toString()}}
);
}
getTimelineForWeek(year: number, month: number, week: number): Observable<ReadingSessionTimelineResponse[]> {
getTimelineForWeek(year: number, week: number): Observable<ReadingSessionTimelineResponse[]> {
return this.http.get<ReadingSessionTimelineResponse[]>(
`${this.readingSessionsUrl}/timeline`,
{ params: { year: year.toString(), month: month.toString(), week: week.toString() } }
{params: {year: year.toString(), week: week.toString()}}
);
}
@@ -79,7 +79,7 @@ export class UserStatsService {
getCompletionTimelineForYear(year: number): Observable<CompletionTimelineResponse[]> {
return this.http.get<CompletionTimelineResponse[]>(
`${this.readingSessionsUrl}/completion-timeline`,
{ params: { year: year.toString() } }
{params: {year: year.toString()}}
);
}
@@ -1,4 +1,4 @@
import {Component, inject, Input, OnDestroy, OnInit} from '@angular/core';
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {ChartConfiguration, ChartData} from 'chart.js';
@@ -32,19 +32,19 @@ export class FavoriteDaysChartComponent implements OnInit, OnDestroy {
public selectedMonth: number | null = null;
public yearOptions: { label: string; value: number | null }[] = [];
public monthOptions: { label: string; value: number | null }[] = [
{ label: 'All Months', value: null },
{ label: 'January', value: 1 },
{ label: 'February', value: 2 },
{ label: 'March', value: 3 },
{ label: 'April', value: 4 },
{ label: 'May', value: 5 },
{ label: 'June', value: 6 },
{ label: 'July', value: 7 },
{ label: 'August', value: 8 },
{ label: 'September', value: 9 },
{ label: 'October', value: 10 },
{ label: 'November', value: 11 },
{ label: 'December', value: 12 }
{label: 'All Months', value: null},
{label: 'January', value: 1},
{label: 'February', value: 2},
{label: 'March', value: 3},
{label: 'April', value: 4},
{label: 'May', value: 5},
{label: 'June', value: 6},
{label: 'July', value: 7},
{label: 'August', value: 8},
{label: 'September', value: 9},
{label: 'October', value: 10},
{label: 'November', value: 11},
{label: 'December', value: 12}
];
constructor() {
@@ -161,7 +161,7 @@ export class FavoriteDaysChartComponent implements OnInit, OnDestroy {
ticks: {
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 11},
callback: function(value) {
callback: function (value) {
return (typeof value === 'number' ? value.toFixed(1) : '0.0') + 'h';
}
},
@@ -186,9 +186,9 @@ export class FavoriteDaysChartComponent implements OnInit, OnDestroy {
private initializeYearOptions(): void {
const currentYear = new Date().getFullYear();
this.yearOptions = [{ label: 'All Years', value: null }];
this.yearOptions = [{label: 'All Years', value: null}];
for (let year = currentYear; year >= currentYear - 10; year--) {
this.yearOptions.push({ label: year.toString(), value: year });
this.yearOptions.push({label: year.toString(), value: year});
}
}
@@ -216,7 +216,7 @@ export class FavoriteDaysChartComponent implements OnInit, OnDestroy {
private updateChartData(favoriteDays: FavoriteDaysResponse[]): void {
const dayMap = new Map<number, FavoriteDaysResponse>();
favoriteDays.forEach(item => {
dayMap.set(item.dayOfWeek, item);
dayMap.set(item.dayOfWeek - 1, item);
});
const labels = this.allDays;
@@ -7,6 +7,16 @@ import {catchError} from 'rxjs/operators';
import {of} from 'rxjs';
import {Select} from 'primeng/select';
import {FormsModule} from '@angular/forms';
import {
addWeeks,
endOfISOWeek,
getISOWeek,
getISOWeeksInYear,
getISOWeekYear,
setISOWeek,
setISOWeekYear,
startOfISOWeek
} from 'date-fns';
interface ReadingSession {
startTime: Date;
@@ -47,7 +57,7 @@ interface DayTimeline {
})
export class ReadingSessionTimelineComponent implements OnInit {
@Input() initialYear: number = new Date().getFullYear();
@Input() weekNumber: number = this.getCurrentWeekNumber();
@Input() weekNumber: number = getISOWeek(new Date());
private userStatsService = inject(UserStatsService);
private urlHelperService = inject(UrlHelperService);
@@ -56,8 +66,8 @@ export class ReadingSessionTimelineComponent implements OnInit {
public hourLabels: string[] = [];
public timelineData: DayTimeline[] = [];
public currentYear: number = new Date().getFullYear();
public currentWeek: number = this.getCurrentWeekNumber();
public currentMonth: number = new Date().getMonth() + 1;
public currentWeek: number = getISOWeek(new Date());
private currentDate: Date = new Date();
public yearOptions: { label: string; value: number }[] = [];
public weekOptions: { label: string; value: number }[] = [];
@@ -65,8 +75,9 @@ export class ReadingSessionTimelineComponent implements OnInit {
ngOnInit(): void {
this.currentYear = this.initialYear;
this.currentWeek = this.weekNumber;
this.updateCurrentMonth();
this.updateDateFromYearAndWeek();
this.initializeYearOptions();
this.ensureYearInOptions();
this.updateWeekOptions();
this.initializeHourLabels();
this.loadReadingSessions();
@@ -76,30 +87,31 @@ export class ReadingSessionTimelineComponent implements OnInit {
const currentYear = new Date().getFullYear();
this.yearOptions = [];
for (let year = currentYear; year >= currentYear - 10; year--) {
this.yearOptions.push({ label: year.toString(), value: year });
this.yearOptions.push({label: year.toString(), value: year});
}
}
private updateWeekOptions(): void {
const weeksInYear = this.getWeeksInYear(this.currentYear);
const weeksInYear = getISOWeeksInYear(this.currentDate);
this.weekOptions = [];
for (let week = 1; week <= weeksInYear; week++) {
this.weekOptions.push({ label: `Week ${week}`, value: week });
this.weekOptions.push({label: `Week ${week}`, value: week});
}
}
public onYearChange(): void {
this.updateWeekOptions();
const maxWeeks = this.getWeeksInYear(this.currentYear);
this.updateDateFromYearAndWeek();
const maxWeeks = getISOWeeksInYear(this.currentDate);
if (this.currentWeek > maxWeeks) {
this.currentWeek = maxWeeks;
this.updateDateFromYearAndWeek();
}
this.updateCurrentMonth();
this.updateWeekOptions();
this.loadReadingSessions();
}
public onWeekChange(): void {
this.updateCurrentMonth();
this.updateDateFromYearAndWeek();
this.loadReadingSessions();
}
@@ -112,7 +124,7 @@ export class ReadingSessionTimelineComponent implements OnInit {
}
private loadReadingSessions(): void {
this.userStatsService.getTimelineForWeek(this.currentYear, this.currentMonth, this.currentWeek)
this.userStatsService.getTimelineForWeek(this.currentYear, this.currentWeek)
.pipe(
catchError((error) => {
console.error('Error loading reading sessions:', error);
@@ -148,59 +160,30 @@ export class ReadingSessionTimelineComponent implements OnInit {
return sessions.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
}
private getCurrentWeekNumber(): number {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
const days = Math.floor((now.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000));
return Math.ceil((days + startOfYear.getDay() + 1) / 7);
}
public changeWeek(delta: number): void {
this.currentWeek += delta;
this.currentDate = addWeeks(this.currentDate, delta);
this.currentYear = getISOWeekYear(this.currentDate);
this.currentWeek = getISOWeek(this.currentDate);
const weeksInYear = this.getWeeksInYear(this.currentYear);
if (this.currentWeek > weeksInYear) {
this.currentWeek = 1;
this.currentYear++;
this.updateWeekOptions();
} else if (this.currentWeek < 1) {
this.currentYear--;
this.currentWeek = this.getWeeksInYear(this.currentYear);
this.updateWeekOptions();
}
this.updateCurrentMonth();
this.ensureYearInOptions();
this.updateWeekOptions();
this.loadReadingSessions();
}
private updateCurrentMonth(): void {
const startOfYear = new Date(this.currentYear, 0, 1);
const daysToAdd = (this.currentWeek - 1) * 7;
const weekStart = new Date(startOfYear.getTime() + daysToAdd * 24 * 60 * 60 * 1000);
const dayOfWeek = weekStart.getDay();
weekStart.setDate(weekStart.getDate() - dayOfWeek);
this.currentMonth = weekStart.getMonth() + 1;
private ensureYearInOptions(): void {
if (!this.yearOptions.some(option => option.value === this.currentYear)) {
this.yearOptions.unshift({label: this.currentYear.toString(), value: this.currentYear});
this.yearOptions.sort((a, b) => b.value - a.value);
}
}
private getWeeksInYear(year: number): number {
const lastDay = new Date(year, 11, 31);
const startOfYear = new Date(year, 0, 1);
const days = Math.floor((lastDay.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000));
return Math.ceil((days + startOfYear.getDay() + 1) / 7);
private updateDateFromYearAndWeek(): void {
this.currentDate = setISOWeek(setISOWeekYear(new Date(), this.currentYear), this.currentWeek);
}
public getWeekDateRange(): string {
const startOfYear = new Date(this.currentYear, 0, 1);
const daysToAdd = (this.currentWeek - 1) * 7;
const weekStart = new Date(startOfYear.getTime() + daysToAdd * 24 * 60 * 60 * 1000);
const dayOfWeek = weekStart.getDay();
weekStart.setDate(weekStart.getDate() - dayOfWeek);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const weekStart = startOfISOWeek(this.currentDate);
const weekEnd = endOfISOWeek(this.currentDate);
const formatDate = (date: Date) => {
const month = date.toLocaleDateString('en-US', {month: 'short'});