From eada60800333947ebf457a8f33f66d1fa0024751 Mon Sep 17 00:00:00 2001 From: Evan Fiordeliso Date: Thu, 3 Jul 2025 17:44:16 -0400 Subject: [PATCH] Add reading stats code block --- esbuild.config.mjs | 6 +- package.json | 8 + pnpm-lock.yaml | 59 ++++++ src/commands/LogReadingFinishedCommand.ts | 8 +- src/main.ts | 6 +- .../ReadingLogCodeBlockView.svelte | 56 ++--- src/ui/code-blocks/ReadingStatsCodeBlock.ts | 116 +++++++++++ .../ReadingStatsCodeBlockView.svelte | 193 ++++++++++++++++++ src/ui/code-blocks/index.ts | 1 + src/ui/components/RatingInput.svelte | 5 - src/ui/components/charts/Bar.svelte | 125 ++++++++++++ src/ui/components/charts/BookAndPages.svelte | 92 +++++++++ src/ui/components/charts/Pie.svelte | 121 +++++++++++ src/ui/components/stats/AverageStat.svelte | 23 +++ src/ui/components/stats/BookCountStat.svelte | 15 ++ src/ui/components/stats/CountStat.svelte | 16 ++ src/ui/components/stats/Stat.svelte | 32 +++ src/ui/components/stats/TotalStat.svelte | 18 ++ src/ui/directives/chart.ts | 73 +++++++ .../settings/BookTrackerSettingTabView.svelte | 48 +++-- src/ui/settings/store.ts | 39 ---- src/ui/stores/metadata.svelte.ts | 190 +++++++++++++++++ src/ui/stores/settings.svelte.ts | 45 ++++ src/utils/ReadingLog.ts | 35 ++-- src/utils/color.ts | 74 +++++++ src/utils/frequencyArray.ts | 15 ++ src/utils/fs.ts | 54 +++++ src/utils/groupBy.ts | 8 + 28 files changed, 1369 insertions(+), 112 deletions(-) create mode 100644 src/ui/code-blocks/ReadingStatsCodeBlock.ts create mode 100644 src/ui/code-blocks/ReadingStatsCodeBlockView.svelte create mode 100644 src/ui/components/charts/Bar.svelte create mode 100644 src/ui/components/charts/BookAndPages.svelte create mode 100644 src/ui/components/charts/Pie.svelte create mode 100644 src/ui/components/stats/AverageStat.svelte create mode 100644 src/ui/components/stats/BookCountStat.svelte create mode 100644 src/ui/components/stats/CountStat.svelte create mode 100644 src/ui/components/stats/Stat.svelte create mode 100644 src/ui/components/stats/TotalStat.svelte create mode 100644 src/ui/directives/chart.ts delete mode 100644 src/ui/settings/store.ts create mode 100644 src/ui/stores/metadata.svelte.ts create mode 100644 src/ui/stores/settings.svelte.ts create mode 100644 src/utils/color.ts create mode 100644 src/utils/frequencyArray.ts create mode 100644 src/utils/fs.ts create mode 100644 src/utils/groupBy.ts diff --git a/esbuild.config.mjs b/esbuild.config.mjs index f85614d..fe0877a 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -54,7 +54,11 @@ const context = await esbuild.context({ plugins: [ esbuildSvelte({ preprocess: sveltePreprocess(), - compilerOptions: { dev: !prod }, + compilerOptions: { + dev: !prod, + warningFilter: (warning) => + !warning.filename?.includes("node_modules"), + }, }), { name: "copy-plugin", diff --git a/package.json b/package.json index 1049175..938c61d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.30.0", "@popperjs/core": "^2.11.8", + "@types/chroma-js": "^3.1.1", "@types/node": "^24.0.6", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", @@ -29,6 +30,7 @@ "globals": "^16.2.0", "handlebars": "^4.7.8", "lucide-svelte": "^0.525.0", + "moment": "^2.30.1", "npm-run-all": "^4.1.5", "obsidian": "latest", "runed": "^0.29.1", @@ -39,5 +41,11 @@ "svelte-preprocess": "^6.0.3", "tslib": "2.4.0", "typescript": "5.0.4" + }, + "dependencies": { + "chart.js": "^4.5.0", + "chroma-js": "^3.1.2", + "yaml": "^2.8.0", + "zod": "^3.25.67" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e6241a..b71b8ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,19 @@ settings: importers: .: + dependencies: + chart.js: + specifier: ^4.5.0 + version: 4.5.0 + chroma-js: + specifier: ^3.1.2 + version: 3.1.2 + yaml: + specifier: ^2.8.0 + version: 2.8.0 + zod: + specifier: ^3.25.67 + version: 3.25.67 devDependencies: '@eslint/eslintrc': specifier: ^3.3.1 @@ -17,6 +30,9 @@ importers: '@popperjs/core': specifier: ^2.11.8 version: 2.11.8 + '@types/chroma-js': + specifier: ^3.1.1 + version: 3.1.1 '@types/node': specifier: ^24.0.6 version: 24.0.6 @@ -53,6 +69,9 @@ importers: lucide-svelte: specifier: ^0.525.0 version: 0.525.0(svelte@5.34.8) + moment: + specifier: ^2.30.1 + version: 2.30.1 npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -308,6 +327,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -413,6 +435,9 @@ packages: peerDependencies: acorn: ^8.9.0 + '@types/chroma-js@3.1.1': + resolution: {integrity: sha512-SFCr4edNkZ1bGaLzGz7rgR1bRzVX4MmMxwsIa3/Bh6ose8v+hRpneoizHv0KChdjxaXyjRtaMq7sCuZSzPomQA==} + '@types/codemirror@5.60.8': resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==} @@ -576,10 +601,17 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chart.js@4.5.0: + resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==} + engines: {pnpm: '>=8'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chroma-js@3.1.2: + resolution: {integrity: sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1124,6 +1156,9 @@ packages: moment@2.29.4: resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1595,6 +1630,11 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + yaml@2.8.0: + resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1602,6 +1642,9 @@ packages: zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} + zod@3.25.67: + resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==} + snapshots: '@ampproject/remapping@2.3.0': @@ -1764,6 +1807,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@kurkle/color@0.3.4': {} + '@marijn/find-cluster-break@1.0.2': {} '@nodelib/fs.scandir@2.1.5': @@ -1845,6 +1890,8 @@ snapshots: dependencies: acorn: 8.15.0 + '@types/chroma-js@3.1.1': {} + '@types/codemirror@5.60.8': dependencies: '@types/tern': 0.23.9 @@ -2034,10 +2081,16 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chart.js@4.5.0: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 + chroma-js@3.1.2: {} + clsx@2.1.1: {} color-convert@1.9.3: @@ -2678,6 +2731,8 @@ snapshots: moment@2.29.4: {} + moment@2.30.1: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -3194,6 +3249,10 @@ snapshots: wordwrap@1.0.0: {} + yaml@2.8.0: {} + yocto-queue@0.1.0: {} zimmerframe@1.1.2: {} + + zod@3.25.67: {} diff --git a/src/commands/LogReadingFinishedCommand.ts b/src/commands/LogReadingFinishedCommand.ts index 1607078..07162f2 100644 --- a/src/commands/LogReadingFinishedCommand.ts +++ b/src/commands/LogReadingFinishedCommand.ts @@ -11,6 +11,7 @@ import type { BookTrackerPluginSettings } from "@ui/settings"; import { RatingModal } from "@ui/modals"; import type { ReadingLog } from "@utils/ReadingLog"; import { READ_STATE } from "@src/const"; +import { mkdirRecursive, dirname } from "@utils/fs"; export class LogReadingFinishedCommand extends EditorCheckCommand { constructor( @@ -51,7 +52,7 @@ export class LogReadingFinishedCommand extends EditorCheckCommand { const fileName = file.basename; const pageCount = this.getPageCount(file); - const rating = await RatingModal.createAndOpen( + const ratings = await RatingModal.createAndOpen( this.app, this.settings.spiceProperty !== "" ); @@ -71,9 +72,9 @@ export class LogReadingFinishedCommand extends EditorCheckCommand { this.app.fileManager.processFrontMatter(file, (frontMatter) => { frontMatter[this.settings.statusProperty] = READ_STATE; frontMatter[this.settings.endDateProperty] = endDate; - frontMatter[this.settings.ratingProperty] = rating; + frontMatter[this.settings.ratingProperty] = ratings.rating; if (this.settings.spiceProperty !== "") { - frontMatter[this.settings.spiceProperty] = rating; + frontMatter[this.settings.spiceProperty] = ratings.spice; } }); @@ -82,6 +83,7 @@ export class LogReadingFinishedCommand extends EditorCheckCommand { const datePath = moment().format("YYYY/MMMM"); const newPath = `${this.settings.readBooksFolder}/${datePath}/${file.name}`; + await mkdirRecursive(this.app.vault, dirname(newPath)); await this.app.vault.rename(file, newPath); } diff --git a/src/main.ts b/src/main.ts index 1604317..b8bc086 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,7 +8,10 @@ import { Templater } from "@utils/Templater"; import { CONTENT_TYPE_EXTENSIONS } from "./const"; import { Storage } from "@utils/Storage"; import { ReadingLog } from "@utils/ReadingLog"; -import { registerReadingLogCodeBlockProcessor } from "@ui/code-blocks"; +import { + registerReadingLogCodeBlockProcessor, + registerReadingStatsCodeBlockProcessor, +} from "@ui/code-blocks"; import type { Book } from "./types"; import { SearchGoodreadsCommand } from "@commands/SearchGoodreadsCommand"; import { LogReadingStartedCommand } from "@commands/LogReadingStartedCommand"; @@ -81,6 +84,7 @@ export default class BookTrackerPlugin extends Plugin { this.addSettingTab(new BookTrackerSettingTab(this)); registerReadingLogCodeBlockProcessor(this); + registerReadingStatsCodeBlockProcessor(this); } onunload() {} diff --git a/src/ui/code-blocks/ReadingLogCodeBlockView.svelte b/src/ui/code-blocks/ReadingLogCodeBlockView.svelte index e8cce85..04d120f 100644 --- a/src/ui/code-blocks/ReadingLogCodeBlockView.svelte +++ b/src/ui/code-blocks/ReadingLogCodeBlockView.svelte @@ -18,11 +18,6 @@ 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"); - } - let entries = $state( plugin.readingLog.getEntries().map((entry, id) => ({ ...entry, @@ -38,33 +33,44 @@ } const years = $derived([ - ...new Set(entries.map((entry) => entry.createdAt.getFullYear())), + ...new Set(entries.map((entry) => entry.createdAt.year())), ]); - let selectedYear = $state(new Date().getFullYear().toString()); + // @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), ); - let selectedMonth = $state( - (new Date().getMonth() + 1).toLocaleString("en-US", { - minimumIntegerDigits: 2, - }), - ); + // @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( - filterYear === ALL_TIME - ? entries - : entries.filter( - (entry) => - entry.createdAt.getFullYear() === filterYear && - (filterMonth === undefined || - entry.createdAt.getMonth() === filterMonth - 1), - ), - ); + 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) + ); + }); + }); function createEntry() { const modal = new ReadingLogEntryEditModal(plugin, async (entry) => { @@ -140,7 +146,7 @@ {#each filteredEntries as entry} - {formatDate(entry.createdAt)} + {entry.createdAt.format("YYYY-MM-DD")} {entry.book} @@ -163,6 +169,10 @@ + {:else} + + No entries found + {/each} diff --git a/src/ui/code-blocks/ReadingStatsCodeBlock.ts b/src/ui/code-blocks/ReadingStatsCodeBlock.ts new file mode 100644 index 0000000..db759c6 --- /dev/null +++ b/src/ui/code-blocks/ReadingStatsCodeBlock.ts @@ -0,0 +1,116 @@ +import { registerCodeBlockRenderer } from "."; +import { SvelteCodeBlockRenderer } from "./SvelteCodeBlockRenderer"; +import ReadingStatsCodeBlockView from "./ReadingStatsCodeBlockView.svelte"; +import type BookTrackerPlugin from "@src/main"; +import * as z from "zod/v4"; +import { COLOR_NAMES } from "@utils/color"; + +export function registerReadingStatsCodeBlockProcessor( + plugin: BookTrackerPlugin +): void { + registerCodeBlockRenderer( + plugin, + "readingstats", + (source, el) => new ReadingStatsCodeBlockRenderer(source, el, plugin) + ); +} + +const PieGroupingSchema = z.object({ + label: z.string(), + min: z.optional(z.number()), + max: z.optional(z.number()), +}); + +export type PieGrouping = z.infer; + +const color = z.union([z.literal("rainbow"), z.enum(COLOR_NAMES)]); + +const PieChartColorSchema = z.union([ + color, + z.array(z.enum(COLOR_NAMES)), + z.array(z.object({ label: z.string(), color: z.enum(COLOR_NAMES) })), +]); + +export type PieChartColor = z.infer; + +const PieChart = z.object({ + type: z.literal("pie"), + property: z.string(), + unit: z.optional(z.string()), + unitPlural: z.optional(z.string()), + groups: z.optional(z.array(PieGroupingSchema)), + responsive: z.optional(z.boolean()), + color: z.optional(PieChartColorSchema), +}); + +const BarChart = z.object({ + type: z.literal("bar"), + property: z.string(), + horizontal: z.optional(z.boolean()), + sortByLabel: z.optional(z.boolean()), + topN: z.optional(z.number()), + unit: z.optional(z.string()), + unitPlural: z.optional(z.string()), + responsive: z.optional(z.boolean()), + color: z.optional(color), +}); + +const LineChart = z.object({ + type: z.literal("line"), + property: z.string(), + unit: z.optional(z.string()), + unitPlural: z.optional(z.string()), + secondProperty: z.optional(z.string()), + secondUnit: z.optional(z.string()), + secondUnitPlural: z.optional(z.string()), + responsive: z.optional(z.boolean()), +}); + +const BooksAndPagesChart = z.object({ + type: z.literal("books-and-pages"), +}); + +export const ReadingStatsSectionSchema = z.object({ + title: z.string(), + stats: z.optional( + z.array( + z.discriminatedUnion("type", [ + z.object({ + type: z.enum(["count", "average", "total"]), + label: z.string(), + property: z.string(), + }), + z.object({ + type: z.literal("book-count"), + label: z.string(), + }), + ]) + ) + ), + charts: z.array( + z.discriminatedUnion("type", [ + PieChart, + BarChart, + LineChart, + BooksAndPagesChart, + ]) + ), +}); + +export type ReadingStatsSection = z.infer; + +export class ReadingStatsCodeBlockRenderer extends SvelteCodeBlockRenderer< + typeof ReadingStatsCodeBlockView +> { + constructor( + source: string, + contentEl: HTMLElement, + plugin: BookTrackerPlugin + ) { + super(contentEl, ReadingStatsCodeBlockView, { + props: { plugin, source }, + }); + } + + onunload() {} +} diff --git a/src/ui/code-blocks/ReadingStatsCodeBlockView.svelte b/src/ui/code-blocks/ReadingStatsCodeBlockView.svelte new file mode 100644 index 0000000..031a377 --- /dev/null +++ b/src/ui/code-blocks/ReadingStatsCodeBlockView.svelte @@ -0,0 +1,193 @@ + + +
+ {#if error} +
{@html error.replace(/\n/g, "
")}
+ {/if} + +
+ + {#if metadataStore.filterYear !== ALL_TIME} + + {/if} +
+ + {#each sections as section} +
+

{section.title}

+
+ {#if section.stats} + {#each section.stats as stat} + {#if stat.type === "average"} + + {:else if stat.type === "count"} + + {:else if stat.type === "book-count"} + + {:else if stat.type === "total"} + + {/if} + {/each} + {/if} +
+
+ {#each section.charts as chart} + {#if chart.type === "bar"} +
+ +
+ {:else if chart.type === "pie"} +
+ +
+ {:else if chart.type === "books-and-pages"} +
+ +
+ {/if} + {/each} +
+
+ {/each} +
+ + diff --git a/src/ui/code-blocks/index.ts b/src/ui/code-blocks/index.ts index 72caada..1a7457e 100644 --- a/src/ui/code-blocks/index.ts +++ b/src/ui/code-blocks/index.ts @@ -44,3 +44,4 @@ export class SvelteCodeBlockRenderer< } export { registerReadingLogCodeBlockProcessor } from "./ReadingLogCodeBlock"; +export { registerReadingStatsCodeBlockProcessor } from "./ReadingStatsCodeBlock"; diff --git a/src/ui/components/RatingInput.svelte b/src/ui/components/RatingInput.svelte index d2738ad..9da2bf7 100644 --- a/src/ui/components/RatingInput.svelte +++ b/src/ui/components/RatingInput.svelte @@ -81,7 +81,6 @@
-
@@ -122,10 +121,6 @@ .rating-input { cursor: pointer; - input { - display: none; - } - .ctrl { position: absolute; z-index: 2; diff --git a/src/ui/components/charts/Bar.svelte b/src/ui/components/charts/Bar.svelte new file mode 100644 index 0000000..0991be5 --- /dev/null +++ b/src/ui/components/charts/Bar.svelte @@ -0,0 +1,125 @@ + + + diff --git a/src/ui/components/charts/BookAndPages.svelte b/src/ui/components/charts/BookAndPages.svelte new file mode 100644 index 0000000..08fc967 --- /dev/null +++ b/src/ui/components/charts/BookAndPages.svelte @@ -0,0 +1,92 @@ + + + diff --git a/src/ui/components/charts/Pie.svelte b/src/ui/components/charts/Pie.svelte new file mode 100644 index 0000000..55929d8 --- /dev/null +++ b/src/ui/components/charts/Pie.svelte @@ -0,0 +1,121 @@ + + + diff --git a/src/ui/components/stats/AverageStat.svelte b/src/ui/components/stats/AverageStat.svelte new file mode 100644 index 0000000..1d805f2 --- /dev/null +++ b/src/ui/components/stats/AverageStat.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/ui/components/stats/BookCountStat.svelte b/src/ui/components/stats/BookCountStat.svelte new file mode 100644 index 0000000..dd65561 --- /dev/null +++ b/src/ui/components/stats/BookCountStat.svelte @@ -0,0 +1,15 @@ + + + diff --git a/src/ui/components/stats/CountStat.svelte b/src/ui/components/stats/CountStat.svelte new file mode 100644 index 0000000..fc0b9ba --- /dev/null +++ b/src/ui/components/stats/CountStat.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/ui/components/stats/Stat.svelte b/src/ui/components/stats/Stat.svelte new file mode 100644 index 0000000..23ce7d8 --- /dev/null +++ b/src/ui/components/stats/Stat.svelte @@ -0,0 +1,32 @@ + + +

+ {label} + {numberFormatter.format(value)} +

+ + diff --git a/src/ui/components/stats/TotalStat.svelte b/src/ui/components/stats/TotalStat.svelte new file mode 100644 index 0000000..06f1476 --- /dev/null +++ b/src/ui/components/stats/TotalStat.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/ui/directives/chart.ts b/src/ui/directives/chart.ts new file mode 100644 index 0000000..d84ddba --- /dev/null +++ b/src/ui/directives/chart.ts @@ -0,0 +1,73 @@ +import { + ArcElement, + BarController, + BarElement, + CategoryScale, + Chart, + Colors, + Legend, + LinearScale, + LineController, + LineElement, + PieController, + PointElement, + Tooltip, + type ChartConfiguration, + type ChartConfigurationCustomTypesPerDataset, + type ChartType, + type DefaultDataPoint, +} from "chart.js"; +import { debounce } from "obsidian"; + +Chart.register( + Colors, + BarController, + BarElement, + CategoryScale, + LinearScale, + Legend, + Tooltip, + PieController, + ArcElement, + LineController, + LineElement, + PointElement +); + +export function chart< + Type extends ChartType = ChartType, + Data = DefaultDataPoint, + Label = unknown +>( + canvas: HTMLCanvasElement, + config: + | ChartConfiguration + | ChartConfigurationCustomTypesPerDataset +) { + const chart = new Chart(canvas, config); + + const resizeObserver = new ResizeObserver( + debounce(() => { + requestAnimationFrame(() => { + chart.resize(); + }); + }, 500) + ); + + resizeObserver.observe(canvas); + + return { + update: ( + config: + | ChartConfiguration + | ChartConfigurationCustomTypesPerDataset + ) => { + chart.data = config.data; + chart.update(); + }, + destroy: () => { + resizeObserver.disconnect(); + chart.destroy(); + }, + }; +} diff --git a/src/ui/settings/BookTrackerSettingTabView.svelte b/src/ui/settings/BookTrackerSettingTabView.svelte index c7ec3ea..1b3718b 100644 --- a/src/ui/settings/BookTrackerSettingTabView.svelte +++ b/src/ui/settings/BookTrackerSettingTabView.svelte @@ -6,7 +6,7 @@ import FieldSuggestItem from "@ui/components/setting/FieldSuggestItem.svelte"; import ToggleItem from "@ui/components/setting/ToggleItem.svelte"; import type BookTrackerPlugin from "@src/main"; - import { createSettingsStore } from "./store"; + import { createSettings } from "@ui/stores/settings.svelte"; import { onMount } from "svelte"; type Props = { @@ -16,92 +16,90 @@ const { plugin }: Props = $props(); const { app } = plugin; - const settings = createSettingsStore(plugin); + const settingsStore = createSettings(plugin); - onMount(async () => { - await settings.load(); - }); + onMount(async () => settingsStore.load());
-
+
-
+
-
+
-
+
diff --git a/src/ui/settings/store.ts b/src/ui/settings/store.ts deleted file mode 100644 index d551913..0000000 --- a/src/ui/settings/store.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type BookTrackerPlugin from "@src/main"; -import { writable, type Writable } from "svelte/store"; -import { type BookTrackerSettings, DEFAULT_SETTINGS } from "./types"; - -type SettingsStore = Writable & { - load: () => Promise; -}; - -export function createSettingsStore(plugin: BookTrackerPlugin): SettingsStore { - const { subscribe, set, update } = - writable(DEFAULT_SETTINGS); - - async function load() { - const settings = await plugin.loadData(); - - update((currentSettings) => { - return { - ...currentSettings, - ...settings, - }; - }); - } - - subscribe((settings) => { - if (settings === DEFAULT_SETTINGS) { - return; - } - - plugin.settings = settings; - plugin.saveSettings(); - }); - - return { - subscribe, - set, - update, - load, - }; -} diff --git a/src/ui/stores/metadata.svelte.ts b/src/ui/stores/metadata.svelte.ts new file mode 100644 index 0000000..7dd9ea7 --- /dev/null +++ b/src/ui/stores/metadata.svelte.ts @@ -0,0 +1,190 @@ +import { READ_STATE } from "@src/const"; +import type { CachedMetadata, TFile } from "obsidian"; +import { getContext, setContext } from "svelte"; +import { getSettingsContext } from "./settings.svelte"; +import type BookTrackerPlugin from "@src/main"; + +export type FileMetadata = { + file: TFile; + frontmatter: Record; +}; + +export type FileProperty = { + file: TFile; + value: any; +}; + +export const ALL_TIME = "ALL_TIME"; + +interface MetadataStore { + get metadata(): FileMetadata[]; + filterYear: number | typeof ALL_TIME; + filterMonth: number | typeof ALL_TIME; + get filterYears(): number[]; + get filterMonths(): { label: string; value: number }[]; + + destroy(): void; +} +export function createMetadata(plugin: BookTrackerPlugin): MetadataStore { + const settings = getSettingsContext().settings; + + const initialMetadata: FileMetadata[] = []; + + for (const file of plugin.app.vault.getMarkdownFiles()) { + const frontmatter = + plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? {}; + + if (frontmatter[settings.statusProperty] !== READ_STATE) { + continue; + } + + 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) => { + if (f.file.path === file.path) { + return { + ...f, + frontmatter: cache.frontmatter ?? {}, + }; + } + return f; + }); + } + + plugin.registerEvent(plugin.app.metadataCache.on("changed", onChanged)); + + function onDeleted(file: TFile) { + metadata = metadata.filter((f) => f.file.path !== file.path); + } + plugin.registerEvent(plugin.app.metadataCache.on("deleted", onDeleted)); + + return { + get metadata() { + return filteredMetadata; + }, + get filterYear() { + return filterYear; + }, + set filterYear(value) { + filterYear = value; + }, + get filterMonth() { + return filterMonth; + }, + set filterMonth(value) { + filterMonth = value; + }, + get filterYears() { + return filterYears; + }, + get filterMonths() { + return filterMonths; + }, + destroy() { + plugin.app.metadataCache.off("changed", onChanged); + plugin.app.metadataCache.off("deleted", onDeleted); + }, + }; +} + +const METADATA_KEY = Symbol("metadata"); + +export function setMetadataContext(state: MetadataStore) { + setContext(METADATA_KEY, state); +} + +export function getMetadataContext(): MetadataStore { + return getContext(METADATA_KEY) as MetadataStore; +} + +function notEmpty(value: any): boolean { + return value !== undefined && value !== null && value !== "" && value !== 0; +} + +interface PropertyStore { + get propertyData(): FileProperty[]; +} + +export function createPropertyStore( + property: string, + filter: (value: any) => boolean = notEmpty +): PropertyStore { + const store = getMetadataContext(); + + const propertyData = $derived( + store.metadata + .map((f) => ({ ...f, value: f.frontmatter[property] })) + .filter((f) => (filter ? filter(f.value) : true)) + ); + + return { + get propertyData() { + return propertyData; + }, + }; +} diff --git a/src/ui/stores/settings.svelte.ts b/src/ui/stores/settings.svelte.ts new file mode 100644 index 0000000..102adfd --- /dev/null +++ b/src/ui/stores/settings.svelte.ts @@ -0,0 +1,45 @@ +import type BookTrackerPlugin from "@src/main"; +import { type BookTrackerSettings, DEFAULT_SETTINGS } from "../settings/types"; +import { getContext, setContext } from "svelte"; + +interface SettingsStore { + settings: BookTrackerSettings; + load(): Promise; +} + +export function createSettings(plugin: BookTrackerPlugin) { + let settings = $state(plugin.settings); + + $effect(() => { + if (settings === DEFAULT_SETTINGS) { + return; + } + + plugin.settings = settings; + plugin.saveSettings(); + }); + + return { + get settings() { + return settings; + }, + async load() { + const newSettings = await plugin.loadData(); + + settings = { + ...settings, + ...newSettings, + }; + }, + }; +} + +const SETTINGS_KEY = Symbol("settings"); + +export function setSettingsContext(state: SettingsStore) { + setContext(SETTINGS_KEY, state); +} + +export function getSettingsContext(): SettingsStore { + return getContext(SETTINGS_KEY) as SettingsStore; +} diff --git a/src/utils/ReadingLog.ts b/src/utils/ReadingLog.ts index e1dbcaf..ec806d4 100644 --- a/src/utils/ReadingLog.ts +++ b/src/utils/ReadingLog.ts @@ -1,19 +1,12 @@ import type { Storage } from "./Storage"; +import type { Moment } from "moment"; export interface ReadingLogEntry { book: string; pagesRead: number; pagesReadTotal: number; pagesRemaining: number; - createdAt: Date; -} - -function isSameDay(a: Date, b: Date): boolean { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); + createdAt: Moment; } export class ReadingLog { @@ -32,20 +25,28 @@ export class ReadingLog { if (entries) { this.entries = entries.map((entry) => ({ ...entry, - createdAt: new Date(entry.createdAt), + // @ts-expect-error Moment is provided by Obsidian + createdAt: moment(entry.createdAt), })); } } private sortEntries() { - this.entries = this.entries.sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime() + this.entries = this.entries.sort((a, b) => + a.createdAt.diff(b.createdAt) ); } async save(filename = "reading-log.json") { this.sortEntries(); - await this.storage.writeJSON(filename, this.entries); + await this.storage.writeJSON( + filename, + this.entries.map((entry) => ({ + ...entry, + // @ts-expect-error Moment is provided by Obsidian + createdAt: moment(entry.createdAt).toISOString(true), + })) + ); } public getEntries(): ReadingLogEntry[] { @@ -82,10 +83,14 @@ export class ReadingLog { : pageEnded, pagesReadTotal: pageEnded, pagesRemaining: pageCount - pageEnded, - createdAt: new Date(), + // @ts-expect-error Moment is provided by Obsidian + createdAt: moment(), }; - if (lastEntry && isSameDay(lastEntry.createdAt, newEntry.createdAt)) { + if ( + lastEntry && + lastEntry.createdAt.isSame(newEntry.createdAt, "day") + ) { newEntry.pagesRead += lastEntry.pagesRead; await this.updateEntry(this.entries.indexOf(lastEntry), newEntry); } else { diff --git a/src/utils/color.ts b/src/utils/color.ts new file mode 100644 index 0000000..a76737c --- /dev/null +++ b/src/utils/color.ts @@ -0,0 +1,74 @@ +import chroma from "chroma-js"; + +const style = getComputedStyle(document.body); + +export const COLOR_NAMES = [ + "red", + "orange", + "yellow", + "green", + "cyan", + "blue", + "purple", + "pink", +] as const; + +export type ColorName = (typeof COLOR_NAMES)[number]; +export type RGB = [number, number, number]; + +export class Color { + constructor( + private readonly r: number, + private readonly g: number, + private readonly b: number, + private readonly a: number = 1 + ) {} + + get hex(): string { + const rHex = this.r.toString(16).padStart(2, "0"); + const gHex = this.g.toString(16).padStart(2, "0"); + const bHex = this.b.toString(16).padStart(2, "0"); + + return `#${rHex}${gHex}${bHex}`; + } + + get rgb(): string { + return `rgb(${this.r}, ${this.g}, ${this.b})`; + } + + get rgba(): string { + return `rgba(${this.r}, ${this.g}, ${this.b}, ${this.a})`; + } + + alpha(alpha: number): Color { + return new Color(this.r, this.g, this.b, alpha); + } + + static fromName(color: ColorName): Color { + const rawRgb = style + .getPropertyValue(`--color-${color}-rgb`) + .split(", "); + + return new Color( + parseInt(rawRgb[0], 10), + parseInt(rawRgb[1], 10), + parseInt(rawRgb[2], 10) + ); + } + + static getAll(): Color[] { + return COLOR_NAMES.map((color) => Color.fromName(color)); + } + + static scale(n: number): Color[] { + const colors = COLOR_NAMES.map((color) => + style.getPropertyValue(`--color-${color}`) + ); + + return chroma + .scale(colors) + .mode("lch") + .colors(n, "rgb") + .map(([r, g, b]) => new Color(r, g, b)); + } +} diff --git a/src/utils/frequencyArray.ts b/src/utils/frequencyArray.ts new file mode 100644 index 0000000..6e878b0 --- /dev/null +++ b/src/utils/frequencyArray.ts @@ -0,0 +1,15 @@ +export type FrequencyArray = { value: T; count: number }[]; + +export function frequencyArray(arr: T[]): FrequencyArray { + const map = new Map(); + + for (const item of arr) { + const count = map.get(item) ?? 0; + map.set(item, count + 1); + } + + return Array.from(map.entries()).map(([value, count]) => ({ + value, + count, + })); +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts new file mode 100644 index 0000000..d5b0db5 --- /dev/null +++ b/src/utils/fs.ts @@ -0,0 +1,54 @@ +import { normalizePath, type Vault } from "obsidian"; + +/** + * A simple analog of Node.js's `path.join(...)`. + * See: https://gist.github.com/creationix/7435851#gistcomment-3698888 + */ +export default function joinPath(...segments: string[]): string { + const parts = segments.reduce((parts, segment) => { + // Remove leading slashes from non-first part. + if (parts.length > 0) { + segment = segment.replace(/^\//, ""); + } + // Remove trailing slashes. + segment = segment.replace(/\/$/, ""); + return parts.concat(segment.split("/")); + }, [] as string[]); + const resultParts: string[] = []; + for (const part of parts) { + if (part === ".") { + continue; + } + if (part === "..") { + resultParts.pop(); + continue; + } + resultParts.push(part); + } + return resultParts.join("/"); +} + +export function dirname(path: string): string { + return joinPath(path, ".."); +} + +export async function mkdirRecursive( + vault: Vault, + path: string +): Promise { + const stack: string[] = []; + let currentPath = normalizePath(path); + + if (await vault.adapter.exists(currentPath)) { + return; + } + + while (!(await vault.adapter.exists(currentPath))) { + stack.push(currentPath); + currentPath = dirname(currentPath); + } + + while (stack.length > 0) { + await vault.adapter.mkdir(stack.pop()!); + } +} diff --git a/src/utils/groupBy.ts b/src/utils/groupBy.ts new file mode 100644 index 0000000..949bcac --- /dev/null +++ b/src/utils/groupBy.ts @@ -0,0 +1,8 @@ +export function groupBy(array: T[], f: (item: T) => string) { + return array.reduce((groups, item) => { + const group = f(item); + groups[group] = groups[group] || []; + groups[group].push(item); + return groups; + }, {} as Record); +}