Skip to content
9 changes: 9 additions & 0 deletions docs/guide/using-librislog/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,12 @@ Click the delete button next to an entry to remove it from the log. A confirmati
## Reading Percentage

For books with a page count, the detail view shows your current page, total pages, reading percentage, and a visual progress bar.

## Reading Sessions & Re-reads

Books can be read more than once. Each reading is tracked as its own session with independent dates and progress.

- **Read again** (book detail dialog, for finished books): starts a fresh reading today, keeping all earlier sessions and progress untouched. Use this for a genuine re-read.
- **Changing status from "read" to "currently reading"** (edit drawer): the legacy correction path. It can keep the previous finish date or clear it, and may leave the book in a "currently reading" state that still carries the old finish date. Use this only to correct a book's status, not to re-read it.

For books that already have reading sessions, the start and finish dates are mirrors of the session and are read-only in the edit drawer; edit them via the reading history instead.
2 changes: 1 addition & 1 deletion docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ LibrisLog v1.10.0 adds four more metadata sources to the import search (BnF, Inv

**Features**
- 📖 **Multiple reading sessions per book.** Each book keeps a list of reading periods (start and finish dates) so re-reads never overwrite earlier dates. A new `reading_session` table stores them; `Book.date_started` / `Book.date_finished` remain a mirror of the current session, so every existing consumer (sorting, timeline, statistics, import/export) keeps working unchanged
- 🔁 **"Read again" action.** A ghost button in the book detail dialog starts a new reading session for a finished book. Earlier sessions and their progress stay untouched, and the progress bar resets to 0 %
- 🔁 **"Read again" action.** A ghost button in the book detail dialog starts a new reading session for a finished book. Earlier sessions and their progress stay untouched, and the progress bar resets to 0 %. This is the recommended way to re-read; changing a book's status from "read" to "currently reading" via the edit drawer is the legacy correction path and can keep the previous finish date
- 📜 **Reading history.** A collapsible block in the book detail dialog lists every session (#, started, finished, status, pages) with per-row edit and delete, plus "Add a past reading" for back-filling handwritten history
- 🏷️ **"read ×N" badge.** Cards, list rows and the book detail dialog show a small badge once a book has been read at least twice
- 📊 **Statistics distinguish unique books from total completions.** A new "Total reads" card shows the number of completed reads (with a "N re-read books" sub-line). `books_finished_per_month/year`, the `books_per_month/year` goals, and `pages_read` now count every completion; "Pages read" in the bucket still counts each book once, so no single-session user sees any change
Expand Down
7 changes: 3 additions & 4 deletions frontend/src/lib/components/BookCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,9 @@
{$_(STATUS_LABEL_KEYS[book.reading_status])}
</span>
{#if (book.read_count ?? 0) >= 2}
<span
class="badge {compact ? 'badge-xs' : 'badge-sm'} badge-outline badge-primary shrink-0"
title={$_('book.readCount', { values: { count: book.read_count } })}
>×{book.read_count}</span>
<span class="tooltip tooltip-bottom" data-tip={$_('book.readCount', { values: { count: book.read_count } })}>
<span class="badge {compact ? 'badge-xs' : 'badge-sm'} badge-outline badge-primary shrink-0">×{book.read_count}</span>
</span>
{/if}
</div>
</div>
Expand Down
32 changes: 20 additions & 12 deletions frontend/src/lib/components/BookDetailDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
import {
formatDate,
formatDateTime,
fromDateInputValue,
fromDateTimeInputValue,
toDateInputValue,
toDateTimeInputValue
} from '#lib/date.js';
import { getTimezone } from '#lib/stores/timezone.js';
import { api } from '#lib/api.js';
import { toasts } from '#lib/toasts.js';
import { formatLanguageCode } from '#lib/utils/language.js';
import { formatAuthors } from '#lib/utils/authors.js';
import AdaptiveDateInput from './AdaptiveDateInput.svelte';
import StarRating from './StarRating.svelte';
import { Line } from 'svelte-chartjs';
import { X } from '@lucide/svelte';
Expand Down Expand Up @@ -175,19 +178,22 @@
try {
const payload: { status: ReadingStatus; date_started: string | null; date_finished: string | null } = {
status: addSessionStatus,
date_started: addSessionStarted ? fromDateTimeInputValue(addSessionStarted, tz) : null,
date_finished: addSessionFinished ? fromDateTimeInputValue(addSessionFinished, tz) : null
date_started: addSessionStarted ? fromDateInputValue(addSessionStarted, tz) : null,
date_finished: addSessionFinished ? fromDateInputValue(addSessionFinished, tz) : null
};
let result;
if (editingSessionId !== null) {
await api.books.sessions.update(book.id, editingSessionId, payload);
result = await api.books.sessions.update(book.id, editingSessionId, payload);
} else {
await api.books.sessions.create(book.id, payload);
result = await api.books.sessions.create(book.id, payload);
}
book = result.book;
onSave?.(result.book);
addSessionOpen = false;
addSessionStarted = '';
addSessionFinished = '';
editingSessionId = null;
await loadSessions();
await loadSessionsAndProgress();
} catch (e: unknown) {
toasts.add(
e instanceof Error ? e.message : $_('common.actionFailed', { values: { action: $_('book.addPastReading') } }),
Expand Down Expand Up @@ -234,8 +240,8 @@

function openEditSession(s: ReadingSession) {
addSessionStatus = s.status === 'currently_reading' ? 'read' : s.status;
addSessionStarted = s.date_started ? toDateTimeInputValue(s.date_started, tz) : '';
addSessionFinished = s.date_finished ? toDateTimeInputValue(s.date_finished, tz) : '';
addSessionStarted = s.date_started ? toDateInputValue(s.date_started, tz) : '';
addSessionFinished = s.date_finished ? toDateInputValue(s.date_finished, tz) : '';
editingSessionId = s.id;
addSessionOpen = true;
}
Expand Down Expand Up @@ -584,9 +590,11 @@
<div class="flex items-center gap-2 shrink-0">
{#if (book.read_count ?? 0) >= 2}
<span
class="badge badge-sm badge-outline badge-primary"
title={$_('book.readCount', { values: { count: book.read_count } })}
>×{book.read_count}</span>
class="tooltip tooltip-bottom"
data-tip={$_('book.readCount', { values: { count: book.read_count } })}
>
<span class="badge badge-sm badge-outline badge-primary">×{book.read_count}</span>
</span>
{/if}
<span class="badge badge-sm {STATUS_BADGE[book.reading_status]} whitespace-nowrap">{$_(STATUS_LABEL_KEYS[book.reading_status])}</span>
</div>
Expand Down Expand Up @@ -938,11 +946,11 @@
</label>
<label class="flex flex-col gap-1">
<span class="label label-text">{$_('book.dateStarted')}</span>
<input type="datetime-local" class="input input-bordered input-sm" bind:value={addSessionStarted} />
<AdaptiveDateInput name="session_date_started" bind:value={addSessionStarted} ariaLabel={$_('book.dateStarted')} />
</label>
<label class="flex flex-col gap-1">
<span class="label label-text">{$_('book.dateFinished')}</span>
<input type="datetime-local" class="input input-bordered input-sm" bind:value={addSessionFinished} />
<AdaptiveDateInput name="session_date_finished" bind:value={addSessionFinished} ariaLabel={$_('book.dateFinished')} />
</label>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" onclick={() => (addSessionOpen = false)}>{$_('common.cancel')}</button>
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/lib/components/BookDrawer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@
let dateFinishedHasInput = $state(false);
let cover_url = $state<string | null>(null);

const hasReadingSessions = $derived((book?.session_count ?? 0) > 0);

// ── Android back button: close drawer instead of navigating away ──────────
let _popClosed = false;

Expand Down Expand Up @@ -600,9 +602,12 @@
bind:value={date_started}
bind:invalid={dateStartedInvalid}
bind:hasInput={dateStartedHasInput}
disabled={hasReadingSessions}
ariaLabel={$_('book.dateStarted')}
/>
{#if dateStartedInvalid && dateStartedHasInput}
{#if hasReadingSessions}
<span class="label label-text-alt text-base-content/50">{$_('book.datesManagedBySessions')}</span>
{:else if dateStartedInvalid && dateStartedHasInput}
<span class="label label-text-alt text-error">{$_('error.invalidDate')}</span>
{/if}
</label>
Expand All @@ -614,9 +619,12 @@
bind:value={date_finished}
bind:invalid={dateFinishedInvalid}
bind:hasInput={dateFinishedHasInput}
disabled={hasReadingSessions}
ariaLabel={$_('book.dateFinished')}
/>
{#if dateFinishedInvalid && dateFinishedHasInput}
{#if hasReadingSessions}
<span class="label label-text-alt text-base-content/50">{$_('book.datesManagedBySessions')}</span>
{:else if dateFinishedInvalid && dateFinishedHasInput}
<span class="label label-text-alt text-error">{$_('error.invalidDate')}</span>
{/if}
</label>
Expand Down
32 changes: 21 additions & 11 deletions frontend/src/lib/components/DateConflictDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -60,25 +60,35 @@
<div class="modal-box max-w-md">
<h3 class="text-lg font-bold">{$_(`dateConflict.${typeKey}.title`)}</h3>
<p class="text-sm text-base-content/70 mt-2">
{$_(`dateConflict.${typeKey}.message`, { values: i18nValues })}
{#if existingDate === suggestedDate && typeKey !== 'startedAfterFinished'}
{$_(`dateConflict.${typeKey}.messageSame`, { values: { date: existingDate } })}
{:else}
{$_(`dateConflict.${typeKey}.message`, { values: i18nValues })}
{/if}
</p>
<div class="modal-action">
<div class="modal-action flex-col sm:flex-row">
<button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>{$_('common.cancel')}</button>
<button type="button" class="btn btn-outline btn-sm" onclick={onKeep}>
{$_(`dateConflict.${typeKey}.${keepKey}`, { values: i18nValues })}
{#if typeKey === 'startedAfterFinished'}<sup>1</sup>{/if}
</button>
<button type="button" class="btn btn-outline btn-sm" onclick={onUseSuggested}>
{$_(`dateConflict.${typeKey}.${useNewKey}`, { values: i18nValues })}
{#if typeKey === 'startedAfterFinished'}<sup>2</sup>{/if}
</button>
{#if existingDate === suggestedDate && typeKey !== 'startedAfterFinished'}
<button type="button" class="btn btn-outline btn-sm" onclick={onKeep}>
{$_(`dateConflict.${typeKey}.${keepKey}`, { values: i18nValues })}
</button>
{:else}
<button type="button" class="btn btn-outline btn-sm" onclick={onKeep}>
{$_(`dateConflict.${typeKey}.${keepKey}`, { values: i18nValues })}
{#if typeKey === 'startedAfterFinished'}<sup>1</sup>{/if}
</button>
<button type="button" class="btn btn-outline btn-sm" onclick={onUseSuggested}>
{$_(`dateConflict.${typeKey}.${useNewKey}`, { values: i18nValues })}
{#if typeKey === 'startedAfterFinished'}<sup>2</sup>{/if}
</button>
{/if}
{#if rereadAvailable && onReread}
<button type="button" class="btn btn-primary btn-sm" onclick={onReread}>
{$_('dateConflict.startNewRead')}
</button>
{/if}
</div>
{#if typeKey === 'startedAfterFinished' && !(rereadAvailable && onReread)}
{#if typeKey === 'startedAfterFinished'}
<div class="border-t border-base-200 pt-3 mt-2 text-xs text-base-content/50 space-y-1">
<p><sup>1</sup> {$_(`dateConflict.${typeKey}.keepDesc`, { values: i18nValues })}</p>
<p><sup>2</sup> {$_(`dateConflict.${typeKey}.clearDesc`, { values: i18nValues })}</p>
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/lib/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@
"sessionReread": "Erneut gelesen",
"sessionDeleteConfirm": "Diese Lesesitzung und ihre Fortschrittseinträge löschen?",
"sessionDelete": "Sitzung löschen",
"readCount": "{count, plural, one {1 Mal} other {# Mal}} gelesen"
"readCount": "{count, plural, one {1 Mal} other {# Mal}} gelesen",
"datesManagedBySessions": "Wird über Lesesitzungen verwaltet"
},
"addModal": {
"manual": "Manuell",
Expand Down Expand Up @@ -342,13 +343,15 @@
"title": "Startdatum bereits gesetzt",
"message": "Startdatum ist bereits gesetzt. Möchtest du {oldDate} behalten oder {newDate} als neues Startdatum setzen?",
"keepOld": "{oldDate} behalten",
"useNew": "{newDate} verwenden"
"useNew": "{newDate} verwenden",
"messageSame": "Startdatum ist bereits auf {date} gesetzt."
},
"finished": {
"title": "Beendigungsdatum bereits gesetzt",
"message": "Beendigungsdatum ist bereits gesetzt. Möchtest du {oldDate} behalten oder {newDate} als neues Beendigungsdatum setzen?",
"keepOld": "{oldDate} behalten",
"useNew": "{newDate} verwenden"
"useNew": "{newDate} verwenden",
"messageSame": "Enddatum ist bereits auf {date} gesetzt."
},
"startedAfterFinished": {
"title": "Buch bereits beendet",
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@
"sessionReread": "Reread",
"sessionDeleteConfirm": "Delete this reading session and its progress entries?",
"sessionDelete": "Delete session",
"readCount": "Read {count, plural, one {1 time} other {# times}}"
"readCount": "Read {count, plural, one {1 time} other {# times}}",
"datesManagedBySessions": "Managed via reading sessions"
},
"addModal": {
"manual": "Manual",
Expand Down Expand Up @@ -342,13 +343,15 @@
"title": "Start date already set",
"message": "Date started is already set. Do you want to keep {oldDate} or set {newDate} as the new date started?",
"keepOld": "Keep {oldDate}",
"useNew": "Use {newDate}"
"useNew": "Use {newDate}",
"messageSame": "Date started is already set to {date}."
},
"finished": {
"title": "Finish date already set",
"message": "Date finished is already set. Do you want to keep {oldDate} or set {newDate} as the new date finished?",
"keepOld": "Keep {oldDate}",
"useNew": "Use {newDate}"
"useNew": "Use {newDate}",
"messageSame": "Date finished is already set to {date}."
},
"startedAfterFinished": {
"title": "Book was already finished",
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/lib/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@
"sessionReread": "Releído",
"sessionDeleteConfirm": "¿Eliminar esta sesión de lectura y sus entradas de progreso?",
"sessionDelete": "Eliminar sesión",
"readCount": "Leído {count, plural, one {1 vez} other {# veces}}"
"readCount": "Leído {count, plural, one {1 vez} other {# veces}}",
"datesManagedBySessions": "Gestionado mediante sesiones de lectura"
},
"addModal": {
"manual": "Manual",
Expand Down Expand Up @@ -342,13 +343,15 @@
"title": "Fecha de inicio ya establecida",
"message": "La fecha de inicio ya está establecida. ¿Quieres conservar {oldDate} o establecer {newDate} como nueva fecha de inicio?",
"keepOld": "Conservar {oldDate}",
"useNew": "Usar {newDate}"
"useNew": "Usar {newDate}",
"messageSame": "La fecha de inicio ya está establecida en {date}."
},
"finished": {
"title": "Fecha de finalización ya establecida",
"message": "La fecha de finalización ya está establecida. ¿Quieres conservar {oldDate} o establecer {newDate} como nueva fecha de finalización?",
"keepOld": "Conservar {oldDate}",
"useNew": "Usar {newDate}"
"useNew": "Usar {newDate}",
"messageSame": "La fecha de fin ya está establecida en {date}."
},
"startedAfterFinished": {
"title": "El libro ya estaba terminado",
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/lib/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@
"sessionReread": "Relu",
"sessionDeleteConfirm": "Supprimer cette session de lecture et ses entrées de progression ?",
"sessionDelete": "Supprimer la session",
"readCount": "Lu {count, plural, one {1 fois} other {# fois}}"
"readCount": "Lu {count, plural, one {1 fois} other {# fois}}",
"datesManagedBySessions": "Géré via les sessions de lecture"
},
"addModal": {
"manual": "Manuel",
Expand Down Expand Up @@ -342,13 +343,15 @@
"title": "Date de début déjà définie",
"message": "La date de début est déjà définie. Veux-tu conserver {oldDate} ou définir {newDate} comme nouvelle date de début ?",
"keepOld": "Conserver {oldDate}",
"useNew": "Utiliser {newDate}"
"useNew": "Utiliser {newDate}",
"messageSame": "La date de début est déjà définie sur {date}."
},
"finished": {
"title": "Date de fin déjà définie",
"message": "La date de fin est déjà définie. Veux-tu conserver {oldDate} ou définir {newDate} comme nouvelle date de fin ?",
"keepOld": "Conserver {oldDate}",
"useNew": "Utiliser {newDate}"
"useNew": "Utiliser {newDate}",
"messageSame": "La date de fin est déjà définie sur {date}."
},
"startedAfterFinished": {
"title": "Livre déjà terminé",
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/lib/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@
"sessionReread": "重读",
"sessionDeleteConfirm": "删除此阅读会话及其进度记录?",
"sessionDelete": "删除会话",
"readCount": "阅读 {count} 次"
"readCount": "阅读 {count} 次",
"datesManagedBySessions": "通过阅读会话管理"
},
"addModal": {
"manual": "手动添加",
Expand Down Expand Up @@ -342,13 +343,15 @@
"title": "开始日期已设置",
"message": "开始日期已设置。你想保留 {oldDate} 还是将 {newDate} 设为新的开始日期?",
"keepOld": "保留 {oldDate}",
"useNew": "使用 {newDate}"
"useNew": "使用 {newDate}",
"messageSame": "开始日期已设置为 {date}。"
},
"finished": {
"title": "完成日期已设置",
"message": "完成日期已设置。你想保留 {oldDate} 还是将 {newDate} 设为新的完成日期?",
"keepOld": "保留 {oldDate}",
"useNew": "使用 {newDate}"
"useNew": "使用 {newDate}",
"messageSame": "结束日期已设置为 {date}。"
},
"startedAfterFinished": {
"title": "本书已完成",
Expand Down
Loading