diff --git a/package.json b/package.json index 938c61d..d5bc881 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dependencies": { "chart.js": "^4.5.0", "chroma-js": "^3.1.2", + "uuid": "^11.1.0", "yaml": "^2.8.0", "zod": "^3.25.67" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b71b8ad..1333920 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: chroma-js: specifier: ^3.1.2 version: 3.1.2 + uuid: + specifier: ^11.1.0 + version: 11.1.0 yaml: specifier: ^2.8.0 version: 2.8.0 @@ -1592,6 +1595,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -3189,6 +3196,8 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@11.1.0: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/src/commands/LogReadingFinishedCommand.ts b/src/commands/LogReadingFinishedCommand.ts index 07162f2..394b3dd 100644 --- a/src/commands/LogReadingFinishedCommand.ts +++ b/src/commands/LogReadingFinishedCommand.ts @@ -58,7 +58,7 @@ export class LogReadingFinishedCommand extends EditorCheckCommand { ); try { - await this.readingLog.addEntry(fileName, pageCount, pageCount); + await this.readingLog.createEntry(fileName, pageCount, pageCount); } catch (error) { new Notice( `Failed to log reading progress for ${fileName}: ${error}` diff --git a/src/commands/LogReadingProgressCommand.ts b/src/commands/LogReadingProgressCommand.ts index f6103ea..459bbd8 100644 --- a/src/commands/LogReadingProgressCommand.ts +++ b/src/commands/LogReadingProgressCommand.ts @@ -59,7 +59,7 @@ export class LogReadingProgressCommand extends EditorCheckCommand { } try { - await this.readingLog.addEntry(fileName, pageNumber, pageCount); + await this.readingLog.createEntry(fileName, pageNumber, pageCount); } catch (error) { new Notice( `Failed to log reading progress for ${fileName}: ${error}` diff --git a/src/commands/ResetReadingStatusCommand.ts b/src/commands/ResetReadingStatusCommand.ts index 1d4b383..27ae0b8 100644 --- a/src/commands/ResetReadingStatusCommand.ts +++ b/src/commands/ResetReadingStatusCommand.ts @@ -43,7 +43,7 @@ export class ResetReadingStatusCommand extends EditorCheckCommand { frontMatter[this.settings.endDateProperty] = ""; }); - this.readingLog.removeEntries(file.basename); + this.readingLog.removeEntriesForBook(file.basename); new Notice("Reading status reset for " + file.basename); } diff --git a/src/ui/code-blocks/ReadingLogCodeBlockView.svelte b/src/ui/code-blocks/ReadingLogCodeBlockView.svelte index 04d120f..e4d7a6a 100644 --- a/src/ui/code-blocks/ReadingLogCodeBlockView.svelte +++ b/src/ui/code-blocks/ReadingLogCodeBlockView.svelte @@ -3,8 +3,9 @@ import { Edit, Trash, Plus } from "lucide-svelte"; import { ReadingLogEntryEditModal } from "@ui/modals"; import type BookTrackerPlugin from "@src/main"; - - const ALL_TIME = "ALL_TIME"; + import { createReadingLog } from "@ui/stores/reading-log.svelte"; + import { ALL_TIME } from "@ui/stores/date-filter.svelte"; + import { onDestroy } from "svelte"; interface Props { plugin: BookTrackerPlugin; @@ -18,112 +19,60 @@ return `obsidian://open?vault=${v}&file=${f}`; } - let entries = $state( - plugin.readingLog.getEntries().map((entry, id) => ({ - ...entry, - id, - })), - ); - - function reload() { - entries = plugin.readingLog.getEntries().map((entry, id) => ({ - ...entry, - id, - })); - } - - const years = $derived([ - ...new Set(entries.map((entry) => entry.createdAt.year())), - ]); - - // @ts-expect-error Moment is provided by Obsidian - let selectedYear = $state(moment().year().toString()); - const filterYear = $derived( - selectedYear === ALL_TIME ? ALL_TIME : parseInt(selectedYear, 10), - ); - - // @ts-expect-error Moment is provided by Obsidian - let selectedMonth = $state(moment().format("MM")); - const filterMonth = $derived( - selectedMonth === "" ? undefined : parseInt(selectedMonth, 10), - ); - - const filteredEntries = $derived.by(() => { - if (filterYear === ALL_TIME) { - return entries; - } - - // @ts-expect-error Moment is provided by Obsidian - let startDate = moment().year(filterYear).startOf("year"); - - // @ts-expect-error Moment is provided by Obsidian - let endDate = moment().year(filterYear).endOf("year"); - - if (filterMonth !== undefined) { - startDate = startDate.month(filterMonth - 1).startOf("month"); - endDate = endDate.month(filterMonth - 1).endOf("month"); - } - - return entries.filter((entry) => { - return ( - entry.createdAt.isSameOrAfter(startDate) && - entry.createdAt.isSameOrBefore(endDate) - ); - }); - }); + const store = createReadingLog(plugin.readingLog); + onDestroy(() => store.destroy()); function createEntry() { const modal = new ReadingLogEntryEditModal(plugin, async (entry) => { modal.close(); - await plugin.readingLog.addRawEntry(entry); - reload(); + await store.addEntry(entry); }); modal.open(); } - function editEntry(i: number, entry: ReadingLogEntry) { + function editEntry(entry: ReadingLogEntry) { const modal = new ReadingLogEntryEditModal( plugin, async (entry) => { modal.close(); - await plugin.readingLog.updateEntry(i, entry); - reload(); + await store.updateEntry(entry); }, entry, ); modal.open(); } - async function deleteEntry(i: number) { - await plugin.readingLog.spliceEntry(i); - reload(); + async function removeEntry(entry: ReadingLogEntry) { + await store.removeEntry(entry); }
- + {#each store.filterYears as year} + {/each} - - - + {#if store.filterYear !== ALL_TIME} + + {/if}
- @@ -171,7 +120,7 @@ {:else} - No entries found + No entries found {/each} @@ -204,10 +153,6 @@ } } - .year-filter:has(> option.all-time:checked) + .month-filter { - display: none; - } - table { width: 100%; @@ -228,6 +173,14 @@ td.actions span { @include utils.visually-hidden; } + + td.no-entries { + text-align: center; + font-size: 1.5rem; + font-weight: var(--bold-weight); + font-style: italic; + padding: var(--size-4-6); + } } } diff --git a/src/ui/code-blocks/ReadingStatsCodeBlockView.svelte b/src/ui/code-blocks/ReadingStatsCodeBlockView.svelte index 031a377..a3daf04 100644 --- a/src/ui/code-blocks/ReadingStatsCodeBlockView.svelte +++ b/src/ui/code-blocks/ReadingStatsCodeBlockView.svelte @@ -7,7 +7,6 @@ import CountStat from "@ui/components/stats/CountStat.svelte"; import TotalStat from "@ui/components/stats/TotalStat.svelte"; import { - ALL_TIME, createMetadata, setMetadataContext, } from "@ui/stores/metadata.svelte"; @@ -23,6 +22,7 @@ } from "@ui/stores/settings.svelte"; import type BookTrackerPlugin from "@src/main"; import BookCountStat from "@ui/components/stats/BookCountStat.svelte"; + import { ALL_TIME } from "@ui/stores/date-filter.svelte"; interface Props { plugin: BookTrackerPlugin; diff --git a/src/ui/components/charts/BookAndPages.svelte b/src/ui/components/charts/BookAndPages.svelte index 08fc967..051eed9 100644 --- a/src/ui/components/charts/BookAndPages.svelte +++ b/src/ui/components/charts/BookAndPages.svelte @@ -1,6 +1,7 @@ diff --git a/src/ui/stores/date-filter.svelte.ts b/src/ui/stores/date-filter.svelte.ts new file mode 100644 index 0000000..f7eda9c --- /dev/null +++ b/src/ui/stores/date-filter.svelte.ts @@ -0,0 +1,97 @@ +import type { Moment } from "moment"; + +export const ALL_TIME = "ALL_TIME"; + +export interface DateFilterStore { + filterYear: number | typeof ALL_TIME; + get filterYears(): number[]; + filterMonth: number | typeof ALL_TIME; + get filterMonths(): { label: string; value: number }[]; +} + +export function createDateFilter( + data: () => T[], + selector: (item: T) => Moment, + initialMonth?: boolean +): DateFilterStore & { + filteredData: T[]; +} { + const today = new Date(); + let filterYear: number | typeof ALL_TIME = $state(today.getFullYear()); + let filterMonth: number | typeof ALL_TIME = $state( + initialMonth ? today.getMonth() + 1 : ALL_TIME + ); + const filteredData = $derived.by(() => { + return data().filter((item) => { + const date = selector(item); + if (filterYear !== ALL_TIME) { + if (date.year() !== filterYear) { + return false; + } + } + + if (filterMonth !== ALL_TIME) { + if (date.month() !== filterMonth - 1) { + return false; + } + } + return true; + }); + }); + + const filterYears = $derived.by(() => { + const years = new Set(); + data().forEach((item) => { + years.add(selector(item).year()); + }); + return Array.from(years).sort((a, b) => a - b); + }); + + const filterMonths = $derived.by(() => { + if (filterYear === ALL_TIME) { + return []; + } + + const months: { label: string; value: number }[] = []; + data().forEach((item) => { + const date = selector(item); + if (date.year() === filterYear) { + months.push({ + label: date.format("MMMM"), + value: date.month() + 1, + }); + } + }); + + return months + .filter( + (month, idx, self) => + idx === self.findIndex((m) => m.value === month.value) + ) + .sort((a, b) => a.value - b.value); + }); + + return { + get filterYear() { + return filterYear; + }, + set filterYear(value) { + filterYear = value; + }, + get filterYears() { + return filterYears; + }, + get filterMonth() { + return filterMonth; + }, + set filterMonth(value) { + filterMonth = value; + }, + get filterMonths() { + return filterMonths; + }, + get filteredData() { + return filteredData; + }, + }; +} diff --git a/src/ui/stores/metadata.svelte.ts b/src/ui/stores/metadata.svelte.ts index 7dd9ea7..b88b951 100644 --- a/src/ui/stores/metadata.svelte.ts +++ b/src/ui/stores/metadata.svelte.ts @@ -3,6 +3,7 @@ import type { CachedMetadata, TFile } from "obsidian"; import { getContext, setContext } from "svelte"; import { getSettingsContext } from "./settings.svelte"; import type BookTrackerPlugin from "@src/main"; +import { createDateFilter, type DateFilterStore } from "./date-filter.svelte"; export type FileMetadata = { file: TFile; @@ -14,14 +15,8 @@ export type FileProperty = { value: any; }; -export const ALL_TIME = "ALL_TIME"; - -interface MetadataStore { +interface MetadataStore extends DateFilterStore { get metadata(): FileMetadata[]; - filterYear: number | typeof ALL_TIME; - filterMonth: number | typeof ALL_TIME; - get filterYears(): number[]; - get filterMonths(): { label: string; value: number }[]; destroy(): void; } @@ -41,68 +36,7 @@ export function createMetadata(plugin: BookTrackerPlugin): MetadataStore { initialMetadata.push({ file, frontmatter }); } - const thisYear = new Date().getFullYear(); - let metadata = $state(initialMetadata); - let filterYear: number | typeof ALL_TIME = $state(thisYear); - let filterMonth: number | typeof ALL_TIME = $state(ALL_TIME); - const filteredMetadata = $derived.by(() => { - console.log("here"); - return metadata.filter((f) => { - // @ts-expect-error Moment is provided by Obsidian - const endDate = moment(f.frontmatter[settings.endDateProperty]); - if (filterYear !== ALL_TIME) { - if (endDate.year() !== filterYear) { - return false; - } - } - - if (filterMonth !== ALL_TIME) { - if (endDate.month() !== filterMonth - 1) { - return false; - } - } - return true; - }); - }); - - const filterYears = $derived.by(() => { - const years = new Set(); - for (const f of metadata) { - // @ts-expect-error Moment is provided by Obsidian - const endDate = moment(f.frontmatter[settings.endDateProperty]); - years.add(endDate.year()); - } - return Array.from(years).sort((a, b) => a - b); - }); - - const filterMonths = $derived.by(() => { - if (filterYear === ALL_TIME) { - return []; - } - - const months = []; - for (const f of metadata) { - // @ts-expect-error Moment is provided by Obsidian - const endDate = moment(f.frontmatter[settings.endDateProperty]); - - if (endDate.year() !== filterYear) { - continue; - } - - months.push({ - label: endDate.format("MMMM"), - value: endDate.month() + 1, - }); - } - - return months - .filter( - (month, idx, self) => - idx === self.findIndex((m) => m.value === month.value) - ) - .sort((a, b) => a.value - b.value); - }); function onChanged(file: TFile, _data: string, cache: CachedMetadata) { metadata = metadata.map((f) => { @@ -123,27 +57,35 @@ export function createMetadata(plugin: BookTrackerPlugin): MetadataStore { } plugin.registerEvent(plugin.app.metadataCache.on("deleted", onDeleted)); + const dateFilter = createDateFilter( + () => metadata, + (f) => { + // @ts-expect-error Moment is provided by Obsidian + return moment(f.frontmatter[settings.endDateProperty]); + } + ); + return { get metadata() { - return filteredMetadata; + return dateFilter.filteredData; }, get filterYear() { - return filterYear; + return dateFilter.filterYear; }, set filterYear(value) { - filterYear = value; + dateFilter.filterYear = value; }, get filterMonth() { - return filterMonth; + return dateFilter.filterMonth; }, set filterMonth(value) { - filterMonth = value; + dateFilter.filterMonth = value; }, get filterYears() { - return filterYears; + return dateFilter.filterYears; }, get filterMonths() { - return filterMonths; + return dateFilter.filterMonths; }, destroy() { plugin.app.metadataCache.off("changed", onChanged); diff --git a/src/ui/stores/reading-log.svelte.ts b/src/ui/stores/reading-log.svelte.ts new file mode 100644 index 0000000..5f5c980 --- /dev/null +++ b/src/ui/stores/reading-log.svelte.ts @@ -0,0 +1,111 @@ +import type { ReadingLog, ReadingLogEntry } from "@utils/ReadingLog"; +import { getContext, setContext } from "svelte"; +import { createDateFilter, type DateFilterStore } from "./date-filter.svelte"; + +export interface ReadingLogStore extends DateFilterStore { + get entries(): ReadingLogEntry[]; + + addEntry(entry: ReadingLogEntry): Promise; + updateEntry(entry: ReadingLogEntry): Promise; + removeEntry(entry: ReadingLogEntry): Promise; + destroy(): void; +} + +export function createReadingLog(readingLog: ReadingLog): ReadingLogStore { + let entries: ReadingLogEntry[] = $state(readingLog.getEntries()); + + const dateFilter = createDateFilter( + () => entries, + (entry) => entry.createdAt, + true + ); + + async function addEntry(entry: ReadingLogEntry): Promise { + await readingLog.createEntry( + entry.book, + entry.pagesRead, + entry.pagesReadTotal + ); + } + + async function updateEntry(entry: ReadingLogEntry): Promise { + await readingLog.updateEntry(entry); + } + + async function removeEntry(entry: ReadingLogEntry): Promise { + await readingLog.removeEntry(entry); + } + + const loadHandler = readingLog.on("load", (payload) => { + entries = payload.entries; + }); + + const createdHandler = readingLog.on("created", (payload) => { + entries.push(payload.entry); + }); + + const updatedHandler = readingLog.on("updated", (payload) => { + const index = entries.findIndex( + (entry) => entry.id === payload.entry.id + ); + if (index !== -1) { + entries[index] = payload.entry; + } + }); + + const removedHandler = readingLog.on("removed", (payload) => { + const index = entries.findIndex( + (entry) => entry.id === payload.entry.id + ); + if (index !== -1) { + entries.splice(index, 1); + } + }); + + const sortedData = $derived( + dateFilter.filteredData.sort((a, b) => a.createdAt.diff(b.createdAt)) + ); + + return { + get entries() { + return sortedData; + }, + get filterYear() { + return dateFilter.filterYear; + }, + set filterYear(value) { + dateFilter.filterYear = value; + }, + get filterMonth() { + return dateFilter.filterMonth; + }, + set filterMonth(value) { + dateFilter.filterMonth = value; + }, + get filterYears() { + return dateFilter.filterYears; + }, + get filterMonths() { + return dateFilter.filterMonths; + }, + addEntry, + updateEntry, + removeEntry, + destroy() { + loadHandler.off(); + createdHandler.off(); + updatedHandler.off(); + removedHandler.off(); + }, + }; +} + +const READING_LOG_KEY = Symbol("readingLog"); + +export function setReadingLogContext(state: ReadingLogStore) { + setContext(READING_LOG_KEY, state); +} + +export function getReadingLogContext(): ReadingLogStore { + return getContext(READING_LOG_KEY); +} diff --git a/src/utils/ReadingLog.ts b/src/utils/ReadingLog.ts index ec806d4..90bff0b 100644 --- a/src/utils/ReadingLog.ts +++ b/src/utils/ReadingLog.ts @@ -1,7 +1,10 @@ import type { Storage } from "./Storage"; import type { Moment } from "moment"; +import { v4 as uuidv4 } from "uuid"; +import { EventEmitter } from "./event"; export interface ReadingLogEntry { + id: string; book: string; pagesRead: number; pagesReadTotal: number; @@ -9,10 +12,19 @@ export interface ReadingLogEntry { createdAt: Moment; } -export class ReadingLog { +interface ReadingLogEventMap { + load: { entries: ReadingLogEntry[] }; + created: { entry: ReadingLogEntry }; + updated: { entry: ReadingLogEntry }; + removed: { entry: ReadingLogEntry }; +} + +export class ReadingLog extends EventEmitter { private entries: ReadingLogEntry[] = []; public constructor(private readonly storage: Storage) { + super(); + this.load().catch((error) => { console.error("Failed to load reading log entries:", error); }); @@ -28,6 +40,7 @@ export class ReadingLog { // @ts-expect-error Moment is provided by Obsidian createdAt: moment(entry.createdAt), })); + this.emit("load", { entries: this.entries }); } } @@ -53,7 +66,7 @@ export class ReadingLog { return this.entries; } - public getLastEntry(book: string): ReadingLogEntry | null { + public getLastEntryForBook(book: string): ReadingLogEntry | null { const entriesForBook = this.entries.filter( (entry) => entry.book === book ); @@ -63,12 +76,12 @@ export class ReadingLog { : null; } - public async addEntry( + public async createEntry( book: string, pageEnded: number, pageCount: number ): Promise { - const lastEntry = this.getLastEntry(book); + const lastEntry = this.getLastEntryForBook(book); if (lastEntry && lastEntry.pagesReadTotal >= pageEnded) { throw new Error( @@ -77,6 +90,7 @@ export class ReadingLog { } const newEntry: ReadingLogEntry = { + id: uuidv4(), book, pagesRead: lastEntry ? pageEnded - lastEntry.pagesReadTotal @@ -91,8 +105,9 @@ export class ReadingLog { lastEntry && lastEntry.createdAt.isSame(newEntry.createdAt, "day") ) { + newEntry.id = lastEntry.id; newEntry.pagesRead += lastEntry.pagesRead; - await this.updateEntry(this.entries.indexOf(lastEntry), newEntry); + await this.updateEntry(newEntry); } else { await this.addRawEntry(newEntry); } @@ -101,35 +116,31 @@ export class ReadingLog { public async addRawEntry(entry: ReadingLogEntry) { this.entries.push(entry); await this.save(); + this.emit("created", { entry }); } - public async removeEntries(book: string): Promise { - this.entries = this.entries.filter((entry) => entry.book !== book); + public async updateEntry(entry: ReadingLogEntry): Promise { + this.entries[this.entries.findIndex((other) => other.id === entry.id)] = + entry; await this.save(); + this.emit("updated", { entry }); } - public async removeLastEntry(book: string): Promise { - const latestEntryIndex = this.entries.findLastIndex( - (entry) => entry.book === book - ); + public async removeEntry(entry: ReadingLogEntry): Promise { + const index = this.entries.findIndex((other) => other.id === entry.id); - if (latestEntryIndex !== -1) { - this.entries.splice(latestEntryIndex, 1); + if (index !== -1) { + const removed = this.entries.splice(index, 1); await this.save(); + this.emit("removed", { entry: removed[0] }); } } - public async updateEntry(i: number, entry: ReadingLogEntry): Promise { - this.entries[i] = entry; + public async removeEntriesForBook(book: string): Promise { + this.entries = this.entries.filter((entry) => entry.book !== book); await this.save(); } - public async spliceEntry(i: number): Promise { - const entry = this.entries.splice(i, 1); - await this.save(); - return entry[0]; - } - public async clearEntries(): Promise { this.entries = []; await this.save(); diff --git a/src/utils/event.ts b/src/utils/event.ts new file mode 100644 index 0000000..4001cf9 --- /dev/null +++ b/src/utils/event.ts @@ -0,0 +1,59 @@ +export type EventHandler = (payload: T) => void; + +export type ListenerRef = { + off(): void; +}; + +export type EventHandlerMap = Record< + T, + EventHandler[] +>; + +export class EventEmitter { + private readonly listeners: EventHandlerMap = + {} as EventHandlerMap; + + public on( + type: T, + handler: EventHandler + ): ListenerRef { + if (!this.listeners[type]) { + this.listeners[type] = []; + } + this.listeners[type].push(handler); + + return { + off() { + this.off(type, handler); + }, + }; + } + + public off( + type: T, + handler: EventHandler + ) { + if (this.listeners[type]) { + this.listeners[type] = this.listeners[type].filter( + (h) => h !== handler + ); + } + } + + public once( + type: T, + handler: EventHandler + ) { + const wrappedHandler = (payload: TEventMap[T]) => { + handler(payload); + this.off(type, wrappedHandler); + }; + this.on(type, wrappedHandler); + } + + public emit(type: T, payload: TEventMap[T]) { + if (this.listeners[type]) { + this.listeners[type].forEach((handler) => handler(payload)); + } + } +}