Add reading log viewer

This commit is contained in:
Evan Fiordeliso 2025-06-28 12:26:45 -04:00
parent 93da69910c
commit a2767f4595
4 changed files with 199 additions and 4 deletions

View File

@ -0,0 +1,135 @@
<script lang="ts">
import type { ReadingLog } from "@utils/storage";
import type { App } from "obsidian";
const ALL_TIME = "ALL_TIME";
interface Props {
app: App;
readingLog: ReadingLog;
}
let { app, readingLog }: Props = $props();
function bookUri(book: string) {
const v = encodeURIComponent(app.vault.getName());
const f = encodeURIComponent(book + ".md");
return `obsidian://open?vault=${v}&file=${f}`;
}
function formatDate(date: Date) {
// @ts-expect-error Moment is provided by Obsidian
return moment(date).format("YYYY-MM-DD");
}
const entries = readingLog.getEntries();
const years = [
...new Set(entries.map((entry) => entry.createdAt.getFullYear())),
];
let selectedYear = $state(new Date().getFullYear().toString());
const filterYear = $derived(
selectedYear === ALL_TIME ? ALL_TIME : parseInt(selectedYear, 10),
);
let selectedMonth = $state(
(new Date().getMonth() + 1).toLocaleString("en-US", {
minimumIntegerDigits: 2,
}),
);
const filterMonth = $derived(
selectedMonth === "" ? undefined : parseInt(selectedMonth, 10),
);
const filteredEntries = $derived(
filterYear === ALL_TIME
? entries
: entries.filter(
(entry) =>
entry.createdAt.getFullYear() === filterYear &&
(filterMonth === undefined ||
entry.createdAt.getMonth() === filterMonth - 1),
),
);
</script>
<div class="obt-reading-log-viewer">
<div class="filters">
<select class="year-filter" bind:value={selectedYear}>
{#each years as year}
<option value={year.toString()}>{year}</option>
{/each}
<option class="all-time" value={ALL_TIME}>All Time</option>
</select>
<select class="month-filter" bind:value={selectedMonth}>
<option value="">Select Month</option>
<option value="01">January</option>
<option value="02">February</option>
<option value="03">March</option>
<option value="04">April</option>
<option value="05">May</option>
<option value="06">June</option>
<option value="07">July</option>
<option value="08">August</option>
<option value="09">September</option>
<option value="10">October</option>
<option value="11">November</option>
<option value="12">December</option>
</select>
</div>
<table>
<thead>
<tr>
<th class="date">Date</th>
<th class="book">Book</th>
<th class="pages-read">Pages Read</th>
<th class="percent-complete">Percent Complete</th>
</tr>
</thead>
<tbody>
{#each filteredEntries as entry}
<tr>
<td class="date">{formatDate(entry.createdAt)}</td>
<td class="book"
><a href={bookUri(entry.book)}>{entry.book}</a></td
>
<td class="pages-read">{entry.pagesRead}</td>
<td class="percent-complete">
{Math.round(
(entry.pagesReadTotal /
(entry.pagesReadTotal + entry.pagesRemaining)) *
100,
)}%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style lang="scss">
.obt-reading-log-viewer {
.year-filter:has(> option.all-time:checked) + .month-filter {
display: none;
}
table {
width: 100%;
td.book {
width: 100%;
}
th,
td:not(.book) {
white-space: nowrap;
}
td.pages-read,
td.percent-complete {
text-align: center;
}
}
}
</style>

View File

@ -17,6 +17,8 @@ import {
import { ReadingLog, Storage } from "@utils/storage";
import { ReadingProgressModal } from "@views/reading-progress-modal";
import { RatingModal } from "@views/rating-modal";
import { renderCodeBlockProcessor } from "@utils/svelte";
import ReadingLogViewer from "@components/ReadingLogViewer.svelte";
export default class BookTrackerPlugin extends Plugin {
settings: BookTrackerPluginSettings;
@ -62,6 +64,14 @@ export default class BookTrackerPlugin extends Plugin {
});
this.addSettingTab(new BookTrackerSettingTab(this.app, this));
this.registerMarkdownCodeBlockProcessor(
"readinglog",
renderCodeBlockProcessor(ReadingLogViewer, {
app: this.app,
readingLog: this.readingLog,
})
);
}
onunload() {}
@ -252,7 +262,7 @@ export default class BookTrackerPlugin extends Plugin {
return;
}
await this.readingLog.addEntry(fileName, pageNumber);
await this.readingLog.addEntry(fileName, pageNumber, pageLength);
new Notice(
`Logged reading progress for ${fileName}: Page ${pageNumber} of ${pageLength}.`
);
@ -299,7 +309,11 @@ export default class BookTrackerPlugin extends Plugin {
const rating = await RatingModal.createAndOpen(this.app);
await this.readingLog.addEntry(activeFile.basename, pageLength);
await this.readingLog.addEntry(
activeFile.basename,
pageLength,
pageLength
);
// @ts-expect-error Moment is provided by Obsidian
const endDate = moment().format("YYYY-MM-DD");

View File

@ -37,6 +37,7 @@ interface ReadingLogEntry {
readonly book: string;
readonly pagesRead: number;
readonly pagesReadTotal: number;
readonly pagesRemaining: number;
readonly createdAt: Date;
}
@ -57,7 +58,10 @@ export class ReadingLog {
"reading-log.json"
);
if (entries) {
this.entries = entries;
this.entries = entries.map((entry) => ({
...entry,
createdAt: new Date(entry.createdAt),
}));
}
}
@ -65,6 +69,10 @@ export class ReadingLog {
await this.plugin.storage.writeJSON("reading-log.json", this.entries);
}
public getEntries(): ReadingLogEntry[] {
return this.entries;
}
public getLatestEntry(book: string): ReadingLogEntry | null {
const entriesForBook = this.entries.filter(
(entry) => entry.book === book
@ -75,7 +83,11 @@ export class ReadingLog {
: null;
}
public async addEntry(book: string, pageEnded: number): Promise<void> {
public async addEntry(
book: string,
pageEnded: number,
pageLength: number
): Promise<void> {
const latestEntry = this.getLatestEntry(book);
const newEntry: ReadingLogEntry = {
@ -84,6 +96,7 @@ export class ReadingLog {
? pageEnded - latestEntry.pagesReadTotal
: pageEnded,
pagesReadTotal: pageEnded,
pagesRemaining: pageLength - pageEnded,
createdAt: new Date(),
};

33
src/utils/svelte.ts Normal file
View File

@ -0,0 +1,33 @@
import type { MarkdownPostProcessorContext } from "obsidian";
import { mount, unmount, type Component, type ComponentProps } from "svelte";
import { MarkdownRenderChild } from "obsidian";
/**
* Renders a svelte component as a code block processor.
* @param component the svelte component to render.
* @param props properties forwarded to the component.
* @param stateProvider an optional provider that handles state & state updates of the code block processor.
*/
export function renderCodeBlockProcessor<C extends Component>(
component: C,
props: ComponentProps<C>
) {
return (
source: string,
containerEl: HTMLElement,
ctx: MarkdownPostProcessorContext
) => {
const svelteComponent = mount(component, {
target: containerEl,
props,
});
class UnloadSvelteComponent extends MarkdownRenderChild {
onunload() {
unmount(svelteComponent);
}
}
ctx.addChild(new UnloadSvelteComponent(containerEl));
};
}