Fixes, new sort option "Last Completed"

This commit is contained in:
Stefan Hardegger
2026-06-18 12:53:22 +02:00
parent a2ed2f7b79
commit 7358a8ee4e
18 changed files with 127 additions and 27 deletions

View File

@@ -604,6 +604,7 @@ public class StoryController {
dto.setReadingPosition(story.getReadingPosition()); dto.setReadingPosition(story.getReadingPosition());
dto.setReadingProgressPercentage(calculateReadingProgressPercentage(story)); dto.setReadingProgressPercentage(calculateReadingProgressPercentage(story));
dto.setLastReadAt(story.getLastReadAt()); dto.setLastReadAt(story.getLastReadAt());
dto.setLastCompletedAt(story.getLastCompletedAt());
if (story.getAuthor() != null) { if (story.getAuthor() != null) {
dto.setAuthorId(story.getAuthor().getId()); dto.setAuthorId(story.getAuthor().getId());
@@ -662,6 +663,7 @@ public class StoryController {
dto.setReadingPosition(story.getReadingPosition()); dto.setReadingPosition(story.getReadingPosition());
dto.setReadingProgressPercentage(calculateReadingProgressPercentage(story)); dto.setReadingProgressPercentage(calculateReadingProgressPercentage(story));
dto.setLastReadAt(story.getLastReadAt()); dto.setLastReadAt(story.getLastReadAt());
dto.setLastCompletedAt(story.getLastCompletedAt());
if (story.getAuthor() != null) { if (story.getAuthor() != null) {
dto.setAuthorId(story.getAuthor().getId()); dto.setAuthorId(story.getAuthor().getId());
@@ -700,6 +702,7 @@ public class StoryController {
dto.setReadingPosition(story.getReadingPosition()); dto.setReadingPosition(story.getReadingPosition());
dto.setReadingProgressPercentage(calculateReadingProgressPercentage(story)); dto.setReadingProgressPercentage(calculateReadingProgressPercentage(story));
dto.setLastReadAt(story.getLastReadAt()); dto.setLastReadAt(story.getLastReadAt());
dto.setLastCompletedAt(story.getLastCompletedAt());
if (story.getAuthor() != null) { if (story.getAuthor() != null) {
dto.setAuthorId(story.getAuthor().getId()); dto.setAuthorId(story.getAuthor().getId());

View File

@@ -33,6 +33,7 @@ public class StoryDto {
private Integer readingPosition; private Integer readingPosition;
private Integer readingProgressPercentage; // Pre-calculated percentage (0-100) private Integer readingProgressPercentage; // Pre-calculated percentage (0-100)
private LocalDateTime lastReadAt; private LocalDateTime lastReadAt;
private LocalDateTime lastCompletedAt;
// Related entities as simple references // Related entities as simple references
private UUID authorId; private UUID authorId;
@@ -164,6 +165,14 @@ public class StoryDto {
this.lastReadAt = lastReadAt; this.lastReadAt = lastReadAt;
} }
public LocalDateTime getLastCompletedAt() {
return lastCompletedAt;
}
public void setLastCompletedAt(LocalDateTime lastCompletedAt) {
this.lastCompletedAt = lastCompletedAt;
}
public UUID getAuthorId() { public UUID getAuthorId() {
return authorId; return authorId;
} }

View File

@@ -27,6 +27,7 @@ public class StoryReadingDto {
private Integer readingPosition; private Integer readingPosition;
private Integer readingProgressPercentage; // Pre-calculated percentage (0-100) private Integer readingProgressPercentage; // Pre-calculated percentage (0-100)
private LocalDateTime lastReadAt; private LocalDateTime lastReadAt;
private LocalDateTime lastCompletedAt;
// Related entities as simple references // Related entities as simple references
private UUID authorId; private UUID authorId;
@@ -153,6 +154,14 @@ public class StoryReadingDto {
this.lastReadAt = lastReadAt; this.lastReadAt = lastReadAt;
} }
public LocalDateTime getLastCompletedAt() {
return lastCompletedAt;
}
public void setLastCompletedAt(LocalDateTime lastCompletedAt) {
this.lastCompletedAt = lastCompletedAt;
}
public UUID getAuthorId() { public UUID getAuthorId() {
return authorId; return authorId;
} }

View File

@@ -20,6 +20,7 @@ public class StorySearchDto {
private Integer readingPosition; private Integer readingPosition;
private Integer readingProgressPercentage; // Pre-calculated percentage (0-100) private Integer readingProgressPercentage; // Pre-calculated percentage (0-100)
private LocalDateTime lastReadAt; private LocalDateTime lastReadAt;
private LocalDateTime lastCompletedAt;
// Author info // Author info
private UUID authorId; private UUID authorId;
@@ -126,6 +127,14 @@ public class StorySearchDto {
this.lastReadAt = lastReadAt; this.lastReadAt = lastReadAt;
} }
public LocalDateTime getLastCompletedAt() {
return lastCompletedAt;
}
public void setLastCompletedAt(LocalDateTime lastCompletedAt) {
this.lastCompletedAt = lastCompletedAt;
}
public Integer getReadingPosition() { public Integer getReadingPosition() {
return readingPosition; return readingPosition;
} }

View File

@@ -25,6 +25,7 @@ public class StorySummaryDto {
private Integer readingPosition; private Integer readingPosition;
private Integer readingProgressPercentage; // Pre-calculated percentage (0-100) private Integer readingProgressPercentage; // Pre-calculated percentage (0-100)
private LocalDateTime lastReadAt; private LocalDateTime lastReadAt;
private LocalDateTime lastCompletedAt;
// Related entities as simple references // Related entities as simple references
private UUID authorId; private UUID authorId;
@@ -144,6 +145,14 @@ public class StorySummaryDto {
this.lastReadAt = lastReadAt; this.lastReadAt = lastReadAt;
} }
public LocalDateTime getLastCompletedAt() {
return lastCompletedAt;
}
public void setLastCompletedAt(LocalDateTime lastCompletedAt) {
this.lastCompletedAt = lastCompletedAt;
}
public UUID getAuthorId() { public UUID getAuthorId() {
return authorId; return authorId;
} }

View File

@@ -64,6 +64,9 @@ public class Story {
@Column(name = "last_read_at") @Column(name = "last_read_at")
private LocalDateTime lastReadAt; private LocalDateTime lastReadAt;
@Column(name = "last_completed_at")
private LocalDateTime lastCompletedAt;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id") @JoinColumn(name = "author_id")
@JsonBackReference("author-stories") @JsonBackReference("author-stories")
@@ -245,6 +248,14 @@ public class Story {
this.lastReadAt = lastReadAt; this.lastReadAt = lastReadAt;
} }
public LocalDateTime getLastCompletedAt() {
return lastCompletedAt;
}
public void setLastCompletedAt(LocalDateTime lastCompletedAt) {
this.lastCompletedAt = lastCompletedAt;
}
public Author getAuthor() { public Author getAuthor() {
return author; return author;
} }
@@ -290,6 +301,10 @@ public class Story {
* When position is 0 or null, resets lastReadAt to null so the story won't appear in "last read" sorting * When position is 0 or null, resets lastReadAt to null so the story won't appear in "last read" sorting
*/ */
public void updateReadingProgress(Integer position) { public void updateReadingProgress(Integer position) {
// Capture completion timestamp when resetting progress that was already in progress
if ((position == null || position == 0) && this.readingPosition != null && this.readingPosition > 0) {
this.lastCompletedAt = LocalDateTime.now();
}
this.readingPosition = position; this.readingPosition = position;
// Only update lastReadAt if there's actual reading progress // Only update lastReadAt if there's actual reading progress
// Reset to null when position is 0 or null to remove from "last read" sorting // Reset to null when position is 0 or null to remove from "last read" sorting
@@ -306,6 +321,7 @@ public class Story {
public void markAsRead() { public void markAsRead() {
this.isRead = true; this.isRead = true;
this.lastReadAt = LocalDateTime.now(); this.lastReadAt = LocalDateTime.now();
this.lastCompletedAt = LocalDateTime.now();
// Set reading position to the end of content if available // Set reading position to the end of content if available
// ALWAYS use contentHtml for consistency (frontend uses contentHtml for position tracking) // ALWAYS use contentHtml for consistency (frontend uses contentHtml for position tracking)
if (contentHtml != null) { if (contentHtml != null) {

View File

@@ -29,7 +29,7 @@ import java.util.stream.Stream;
public class AutomaticBackupService { public class AutomaticBackupService {
private static final Logger logger = LoggerFactory.getLogger(AutomaticBackupService.class); private static final Logger logger = LoggerFactory.getLogger(AutomaticBackupService.class);
private static final int MAX_BACKUPS = 5; private static final int MAX_BACKUPS = 10;
private static final DateTimeFormatter FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); private static final DateTimeFormatter FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
@Value("${storycove.automatic-backup.dir:/app/automatic-backups}") @Value("${storycove.automatic-backup.dir:/app/automatic-backups}")

View File

@@ -353,6 +353,10 @@ public class SolrService {
doc.addField("lastReadAt", formatDateTime(story.getLastReadAt())); doc.addField("lastReadAt", formatDateTime(story.getLastReadAt()));
} }
if (story.getLastCompletedAt() != null) {
doc.addField("lastCompletedAt", formatDateTime(story.getLastCompletedAt()));
}
if (story.getAuthor() != null) { if (story.getAuthor() != null) {
doc.addField("authorId", story.getAuthor().getId().toString()); doc.addField("authorId", story.getAuthor().getId().toString());
doc.addField("authorName", story.getAuthor().getName()); doc.addField("authorName", story.getAuthor().getName());
@@ -1064,6 +1068,7 @@ public class SolrService {
// Handle dates // Handle dates
story.setLastReadAt(parseDateTimeFromSolr(doc.getFieldValue("lastReadAt"))); story.setLastReadAt(parseDateTimeFromSolr(doc.getFieldValue("lastReadAt")));
story.setLastCompletedAt(parseDateTimeFromSolr(doc.getFieldValue("lastCompletedAt")));
story.setCreatedAt(parseDateTimeFromSolr(doc.getFieldValue("createdAt"))); story.setCreatedAt(parseDateTimeFromSolr(doc.getFieldValue("createdAt")));
story.setUpdatedAt(parseDateTimeFromSolr(doc.getFieldValue("updatedAt"))); story.setUpdatedAt(parseDateTimeFromSolr(doc.getFieldValue("updatedAt")));
story.setDateAdded(parseDateTimeFromSolr(doc.getFieldValue("dateAdded"))); story.setDateAdded(parseDateTimeFromSolr(doc.getFieldValue("dateAdded")));

View File

@@ -22,7 +22,7 @@ public class ZIPImportService {
private static final Logger log = LoggerFactory.getLogger(ZIPImportService.class); private static final Logger log = LoggerFactory.getLogger(ZIPImportService.class);
private static final long MAX_ZIP_SIZE = 1024L * 1024 * 1024; // 1GB private static final long MAX_ZIP_SIZE = 1024L * 1024 * 1024; // 1GB
private static final int MAX_FILES_IN_ZIP = 30; private static final int MAX_FILES_IN_ZIP = 100;
private static final long ZIP_SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes private static final long ZIP_SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
// Temporary storage for extracted ZIP files (sessionId -> session data) // Temporary storage for extracted ZIP files (sessionId -> session data)

View File

@@ -76,7 +76,7 @@ storycove:
# Query settings # Query settings
query: query:
default-rows: ${SOLR_DEFAULT_ROWS:10} default-rows: ${SOLR_DEFAULT_ROWS:10}
max-rows: ${SOLR_MAX_ROWS:1000} max-rows: ${SOLR_MAX_ROWS:5000}
default-operator: ${SOLR_DEFAULT_OPERATOR:AND} default-operator: ${SOLR_DEFAULT_OPERATOR:AND}
highlight: ${SOLR_ENABLE_HIGHLIGHT:true} highlight: ${SOLR_ENABLE_HIGHLIGHT:true}
facets: ${SOLR_ENABLE_FACETS:true} facets: ${SOLR_ENABLE_FACETS:true}

View File

@@ -141,6 +141,7 @@ class StoryServiceTest {
assertTrue(result.getIsRead()); assertTrue(result.getIsRead());
assertNotNull(result.getLastReadAt()); assertNotNull(result.getLastReadAt());
assertNotNull(result.getLastCompletedAt());
// When marked as read, position should be set to content length // When marked as read, position should be set to content length
assertTrue(result.getReadingPosition() > 0); assertTrue(result.getReadingPosition() > 0);
verify(storyRepository).findById(testId); verify(storyRepository).findById(testId);
@@ -221,6 +222,40 @@ class StoryServiceTest {
assertNotNull(result.getLastReadAt()); assertNotNull(result.getLastReadAt());
assertTrue(result.getLastReadAt().isAfter(beforeUpdate)); assertTrue(result.getLastReadAt().isAfter(beforeUpdate));
assertNotNull(result.getLastCompletedAt());
assertTrue(result.getLastCompletedAt().isAfter(beforeUpdate));
verify(storyRepository).save(testStory);
}
@Test
@DisplayName("Should set lastCompletedAt when resetting progress from non-zero position")
void shouldSetLastCompletedAtWhenResettingProgress() {
LocalDateTime beforeUpdate = LocalDateTime.now().minusMinutes(1);
// Give the story some reading progress first
testStory.updateReadingProgress(100);
when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory));
when(storyRepository.save(any(Story.class))).thenReturn(testStory);
Story result = storyService.updateReadingProgress(testId, 0);
assertEquals(0, result.getReadingPosition());
assertNull(result.getLastReadAt());
assertNotNull(result.getLastCompletedAt());
assertTrue(result.getLastCompletedAt().isAfter(beforeUpdate));
verify(storyRepository).save(testStory);
}
@Test
@DisplayName("Should not set lastCompletedAt when resetting progress that was already zero")
void shouldNotSetLastCompletedAtWhenAlreadyAtZeroProgress() {
// testStory starts at position 0, no prior reading
when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory));
when(storyRepository.save(any(Story.class))).thenReturn(testStory);
Story result = storyService.updateReadingProgress(testId, 0);
assertNull(result.getLastCompletedAt());
verify(storyRepository).save(testStory); verify(storyRepository).save(testStory);
} }
} }

View File

@@ -80,16 +80,16 @@ services:
ports: ports:
- "8983:8983" # Expose Solr Admin UI for development - "8983:8983" # Expose Solr Admin UI for development
environment: environment:
- SOLR_HEAP=512m - SOLR_HEAP=1024m
- SOLR_JAVA_MEM=-Xms256m -Xmx512m - SOLR_JAVA_MEM=-Xms512m -Xmx1024m
volumes: volumes:
- /volume1/docker/storycove/solr:/var/solr - /volume1/docker/storycove/solr:/var/solr
deploy: deploy:
resources: resources:
limits: limits:
memory: 1G memory: 2G
reservations: reservations:
memory: 512M memory: 1G
stop_grace_period: 30s stop_grace_period: 30s
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8983/solr/admin/ping || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:8983/solr/admin/ping || exit 1"]

View File

@@ -16,7 +16,7 @@ import { useLibraryLayout } from '../../hooks/useLibraryLayout';
import { useLibraryFilters, clearLibraryFilters } from '../../hooks/useLibraryFilters'; import { useLibraryFilters, clearLibraryFilters } from '../../hooks/useLibraryFilters';
type ViewMode = 'grid' | 'list'; type ViewMode = 'grid' | 'list';
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastReadAt'; type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastReadAt' | 'lastCompletedAt';
export default function LibraryContent() { export default function LibraryContent() {
const router = useRouter(); const router = useRouter();

View File

@@ -67,6 +67,7 @@ export default function MinimalLayout({
const getSortDisplayText = () => { const getSortDisplayText = () => {
const sortLabels: Record<string, string> = { const sortLabels: Record<string, string> = {
lastReadAt: 'Last Read', lastReadAt: 'Last Read',
lastCompletedAt: 'Last Completed',
createdAt: 'Date Added', createdAt: 'Date Added',
title: 'Title', title: 'Title',
authorName: 'Author', authorName: 'Author',

View File

@@ -124,6 +124,7 @@ export default function SidebarLayout({
> >
<option value="lastReadAt_desc">Last Read </option> <option value="lastReadAt_desc">Last Read </option>
<option value="lastReadAt_asc">Last Read </option> <option value="lastReadAt_asc">Last Read </option>
<option value="lastCompletedAt_desc">Last Completed </option>
<option value="createdAt_desc">Date Added </option> <option value="createdAt_desc">Date Added </option>
<option value="createdAt_asc">Date Added </option> <option value="createdAt_asc">Date Added </option>
<option value="title_asc">Title </option> <option value="title_asc">Title </option>
@@ -227,6 +228,7 @@ export default function SidebarLayout({
className="flex-1 px-3 py-2 border rounded-lg theme-card border-gray-300 dark:border-gray-600" className="flex-1 px-3 py-2 border rounded-lg theme-card border-gray-300 dark:border-gray-600"
> >
<option value="lastReadAt">Last Read</option> <option value="lastReadAt">Last Read</option>
<option value="lastCompletedAt">Last Completed</option>
<option value="createdAt">Date Added</option> <option value="createdAt">Date Added</option>
<option value="title">Title</option> <option value="title">Title</option>
<option value="authorName">Author</option> <option value="authorName">Author</option>

View File

@@ -112,6 +112,7 @@ export default function ToolbarLayout({
> >
<option value="lastReadAt_desc">Sort: Last Read </option> <option value="lastReadAt_desc">Sort: Last Read </option>
<option value="lastReadAt_asc">Sort: Last Read </option> <option value="lastReadAt_asc">Sort: Last Read </option>
<option value="lastCompletedAt_desc">Sort: Last Completed </option>
<option value="createdAt_desc">Sort: Date Added </option> <option value="createdAt_desc">Sort: Date Added </option>
<option value="createdAt_asc">Sort: Date Added </option> <option value="createdAt_asc">Sort: Date Added </option>
<option value="title_asc">Sort: Title </option> <option value="title_asc">Sort: Title </option>

View File

@@ -18,6 +18,7 @@ export interface Story {
readingPosition?: number; readingPosition?: number;
readingProgressPercentage?: number; // Pre-calculated percentage (0-100) from backend readingProgressPercentage?: number; // Pre-calculated percentage (0-100) from backend
lastReadAt?: string; lastReadAt?: string;
lastCompletedAt?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

File diff suppressed because one or more lines are too long