Release Smart Review 0.2.1

This commit is contained in:
Jaycelu 2026-06-23 20:19:42 +08:00
parent 8e9c3880ef
commit 460caf6764
12 changed files with 244 additions and 32 deletions

View file

@ -3,7 +3,7 @@ import { getLocalDateString, normalizeDate, type ReviewIndex, type ReviewItem, t
import type { SmartReviewSettings } from "./settings";
import { DEFAULT_SETTINGS } from "./settings";
import { buildHeatmapDays, getDateDaysAgo, incrementDateCount } from "./heatmap";
import { UNCATEGORIZED_DOMAIN, type DistributionItem, type ReviewHistoryDetail, type SmartReviewAnalytics } from "./analytics-types";
import { UNCATEGORIZED_DOMAIN, type DistributionItem, type NoteCreationDetail, type ReviewHistoryDetail, type SmartReviewAnalytics } from "./analytics-types";
import {
collectTags,
getOptionalString,
@ -28,6 +28,7 @@ interface VaultMetadataStats {
notesWithNextReview: number;
metadataCompleteness: number;
creationCountsByDate: Map<string, number>;
creationDetails: NoteCreationDetail[];
}
const RATING_NAMES: ReviewRating[] = ["again", "hard", "good", "easy"];
@ -95,7 +96,8 @@ export async function buildSmartReviewAnalytics(
byState: buildDistribution(index.items.map((item) => item.review_state))
},
details: {
reviewHistory: history.map(toReviewHistoryDetail)
reviewHistory: history.map(toReviewHistoryDetail),
noteCreation: vaultStats.creationDetails
}
};
}
@ -148,6 +150,7 @@ function parseReviewHistoryLine(line: string): ReviewHistoryRecord[] {
function collectVaultMetadataStats(app: App, settings: SmartReviewSettings): VaultMetadataStats {
const creationCountsByDate = new Map<string, number>();
const creationDetails: NoteCreationDetail[] = [];
let activeNotes = 0;
let notesWithNextReview = 0;
let completenessTotal = 0;
@ -165,14 +168,21 @@ function collectVaultMetadataStats(app: App, settings: SmartReviewSettings): Vau
}
completenessTotal += calculateMetadataCompleteness(frontmatter, file, cache);
incrementDateCount(creationCountsByDate, getCreationDate(frontmatter, file));
const creationDate = getCreationDate(frontmatter, file);
incrementDateCount(creationCountsByDate, creationDate);
creationDetails.push({
date: creationDate,
file: file.path,
title: getOptionalString(frontmatter.title) ?? file.basename
});
}
return {
activeNotes,
notesWithNextReview,
metadataCompleteness: activeNotes > 0 ? completenessTotal / activeNotes : 1,
creationCountsByDate
creationCountsByDate,
creationDetails
};
}

View file

@ -34,6 +34,7 @@ export interface SmartReviewAnalytics {
};
details: {
reviewHistory: ReviewHistoryDetail[];
noteCreation: NoteCreationDetail[];
};
}
@ -57,4 +58,10 @@ export interface ReviewHistoryDetail {
nextReview: string | null;
}
export interface NoteCreationDetail {
date: string;
file: string;
title: string;
}
export type ReviewStateDistributionName = ReviewState | "unknown";

View file

@ -63,6 +63,10 @@ const TRANSLATIONS = {
creationHeatmapTitle: "Vault Creation Heatmap",
creationHeatmapSubtitle: "Based on frontmatter.created or file timestamps from the past 365 days",
creationUnit: "notes",
heatmapClickHint: "Click to view details",
reviewDayDetail: "Reviews on {date}",
creationDayDetail: "Notes created on {date}",
createdAt: "created",
noChartData: "No data to show.",
domainDistribution: "Domain Distribution",
ratingDistribution: "Rating Distribution",
@ -204,6 +208,10 @@ const TRANSLATIONS = {
creationHeatmapTitle: "笔记创建热力图",
creationHeatmapSubtitle: "基于 frontmatter.created 或文件时间统计过去 365 天的笔记创建情况",
creationUnit: "篇笔记",
heatmapClickHint: "点击查看详情",
reviewDayDetail: "{date} 的复习记录",
creationDayDetail: "{date} 创建的笔记",
createdAt: "创建日期",
noChartData: "暂无可展示的数据。",
domainDistribution: "领域分布",
ratingDistribution: "评分分布",

View file

@ -1,7 +1,7 @@
{
"id": "smart-review",
"name": "Smart Review",
"version": "0.2.0",
"version": "0.2.1",
"minAppVersion": "1.5.0",
"description": "Review center for Properties and YAML frontmatter with spaced feedback, history, and AI payload export.",
"author": "Jayce",

View file

@ -1,6 +1,6 @@
{
"name": "@smart-review/obsidian-plugin",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"type": "module",
"scripts": {

View file

@ -1,6 +1,6 @@
import { ItemView, Notice, type WorkspaceLeaf } from "obsidian";
import type { ReviewItem, ReviewRating, ReviewState } from "@smart-review/shared";
import { UNCATEGORIZED_DOMAIN, type DistributionItem, type HeatmapDay, type ReviewHistoryDetail, type SmartReviewAnalytics } from "./analytics-types";
import { UNCATEGORIZED_DOMAIN, type DistributionItem, type HeatmapDay, type NoteCreationDetail, type ReviewHistoryDetail, type SmartReviewAnalytics } from "./analytics-types";
import { t, type SmartReviewLocale, type SmartReviewTranslationKey } from "./i18n";
import type SmartReviewPlugin from "./main";
import { REVIEW_RATINGS } from "./settings";
@ -23,16 +23,24 @@ const PLAN_GROUPS: PlanGroup[] = [
];
type DrilldownKind = "domain" | "tag" | "rating";
type HeatmapKind = "review" | "creation";
interface DrilldownSelection {
kind: DrilldownKind;
name: string;
}
interface HeatmapSelection {
kind: HeatmapKind;
date: string;
}
export class ReviewCenterView extends ItemView {
private readonly expandedGroups = new Set<ReviewState>();
private selectedDrilldown: DrilldownSelection | null = null;
private drilldownExpanded = false;
private selectedHeatmap: HeatmapSelection | null = null;
private heatmapExpanded = false;
constructor(leaf: WorkspaceLeaf, private readonly plugin: SmartReviewPlugin) {
super(leaf);
@ -90,8 +98,8 @@ export class ReviewCenterView extends ItemView {
this.renderMetrics(container, analytics);
this.renderTaskFlow(container, analytics);
this.renderHeatmapSection(container, t(this.plugin.locale, "reviewHeatmapTitle"), t(this.plugin.locale, "reviewHeatmapSubtitle"), analytics.heatmaps.reviewActivity, t(this.plugin.locale, "reviewUnit"));
this.renderHeatmapSection(container, t(this.plugin.locale, "creationHeatmapTitle"), t(this.plugin.locale, "creationHeatmapSubtitle"), analytics.heatmaps.noteCreation, t(this.plugin.locale, "creationUnit"));
this.renderHeatmapSection(container, t(this.plugin.locale, "reviewHeatmapTitle"), t(this.plugin.locale, "reviewHeatmapSubtitle"), analytics.heatmaps.reviewActivity, t(this.plugin.locale, "reviewUnit"), "review", analytics);
this.renderHeatmapSection(container, t(this.plugin.locale, "creationHeatmapTitle"), t(this.plugin.locale, "creationHeatmapSubtitle"), analytics.heatmaps.noteCreation, t(this.plugin.locale, "creationUnit"), "creation", analytics);
this.renderDistributions(container, analytics);
}
@ -263,7 +271,7 @@ export class ReviewCenterView extends ItemView {
this.renderProgress(row, analytics.taskFlow.completionRate);
}
private renderHeatmapSection(container: HTMLElement, title: string, subtitle: string, days: HeatmapDay[], unit: string): void {
private renderHeatmapSection(container: HTMLElement, title: string, subtitle: string, days: HeatmapDay[], unit: string, kind: HeatmapKind, analytics: SmartReviewAnalytics): void {
const section = container.createDiv({ cls: "smart-review-heatmap-section" });
this.renderSectionHeading(section, title, subtitle);
@ -280,10 +288,69 @@ export class ReviewCenterView extends ItemView {
const heatmap = viewport.createDiv({ cls: "smart-review-heatmap" });
for (const [index, day] of days.entries()) {
const cell = heatmap.createDiv({ cls: `smart-review-heatmap-cell smart-review-heatmap-level-${day.level}` });
const selected = this.selectedHeatmap?.kind === kind && this.selectedHeatmap.date === day.date;
const cell = heatmap.createEl("button", {
cls: `smart-review-heatmap-cell smart-review-heatmap-level-${day.level}${selected ? " smart-review-heatmap-cell-selected" : ""}`
});
cell.setAttr("style", `grid-column: ${Math.floor(index / 7) + 1}; grid-row: ${(index % 7) + 1};`);
cell.setAttr("type", "button");
cell.setAttr("title", `${day.date}: ${day.count} ${unit}`);
cell.setAttr("aria-label", `${day.date}: ${day.count} ${unit}`);
cell.setAttr("aria-label", `${day.date}: ${day.count} ${unit}. ${t(this.plugin.locale, "heatmapClickHint")}`);
cell.setAttr("data-tooltip", `${day.date}\A${day.count} ${unit}\A${t(this.plugin.locale, "heatmapClickHint")}`);
cell.onclick = () => {
this.selectedHeatmap = selected ? null : { kind, date: day.date };
this.heatmapExpanded = false;
this.render();
if (!selected) {
this.focusHeatmapDetailAfterRender();
}
};
}
this.renderHeatmapDrilldown(section, kind, analytics);
}
private renderHeatmapDrilldown(container: HTMLElement, kind: HeatmapKind, analytics: SmartReviewAnalytics): void {
if (this.selectedHeatmap === null || this.selectedHeatmap.kind !== kind) {
return;
}
const rows = getHeatmapRows(this.selectedHeatmap, analytics);
const panel = container.createDiv({ cls: "smart-review-drilldown-panel smart-review-heatmap-detail-panel" });
panel.setAttr("tabindex", "-1");
const header = panel.createDiv({ cls: "smart-review-drilldown-header" });
const title = header.createDiv({ cls: "smart-review-drilldown-title" });
title.createEl("h3", { text: getHeatmapDrilldownTitle(this.plugin.locale, this.selectedHeatmap) });
title.createSpan({ cls: "smart-review-subtle-badge", text: t(this.plugin.locale, "detailCount", { count: rows.length }) });
const actions = header.createDiv({ cls: "smart-review-drilldown-actions" });
if (rows.length > 10) {
const toggle = actions.createEl("button", {
cls: "smart-review-link-button",
text: this.heatmapExpanded ? t(this.plugin.locale, "collapse") : t(this.plugin.locale, "expandAll")
});
toggle.onclick = () => {
this.heatmapExpanded = !this.heatmapExpanded;
this.render();
this.focusHeatmapDetailAfterRender();
};
}
const close = actions.createEl("button", { cls: "smart-review-link-button", text: t(this.plugin.locale, "close") });
close.onclick = () => {
this.selectedHeatmap = null;
this.heatmapExpanded = false;
this.render();
};
if (rows.length === 0) {
panel.createDiv({ cls: "smart-review-empty-state smart-review-empty-inline", text: t(this.plugin.locale, "noDetailRecords") });
return;
}
const list = panel.createDiv({ cls: "smart-review-drilldown-list" });
for (const row of this.heatmapExpanded ? rows : rows.slice(0, 10)) {
this.renderDrilldownRow(list, row);
}
}
@ -371,7 +438,7 @@ export class ReviewCenterView extends ItemView {
}
}
private renderDrilldownRow(container: HTMLElement, row: ReviewItem | ReviewHistoryDetail): void {
private renderDrilldownRow(container: HTMLElement, row: ReviewItem | ReviewHistoryDetail | NoteCreationDetail): void {
const item = container.createDiv({ cls: "smart-review-drilldown-row" });
const main = item.createDiv({ cls: "smart-review-drilldown-main" });
const title = main.createEl("button", { text: row.title, cls: "smart-review-task-title" });
@ -395,10 +462,12 @@ export class ReviewCenterView extends ItemView {
renderMeta(meta, t(this.plugin.locale, "statusLabel"), stateLabel(this.plugin.locale, row));
renderMeta(meta, "next_review", row.next_review ?? t(this.plugin.locale, "invalid"));
renderMeta(meta, "domain", row.domain);
} else {
} else if ("reviewedAt" in row) {
renderMeta(meta, t(this.plugin.locale, "reviewedAt"), row.reviewedAt);
renderMeta(meta, t(this.plugin.locale, "rating"), row.rating);
renderMeta(meta, "next_review", row.nextReview ?? t(this.plugin.locale, "noRecord"));
} else {
renderMeta(meta, t(this.plugin.locale, "createdAt"), row.date);
}
}
@ -419,6 +488,23 @@ export class ReviewCenterView extends ItemView {
}, 0);
}
private focusHeatmapDetailAfterRender(): void {
window.setTimeout(() => {
const container = this.containerEl.children[1] as HTMLElement;
const panel = container.querySelector<HTMLElement>(".smart-review-heatmap-detail-panel");
if (panel === null) {
return;
}
panel.addClass("smart-review-drilldown-panel-focus");
panel.scrollIntoView({ behavior: "smooth", block: "start" });
panel.focus({ preventScroll: true });
window.setTimeout(() => {
panel.removeClass("smart-review-drilldown-panel-focus");
}, 1_200);
}, 0);
}
private renderSectionHeading(container: HTMLElement, title: string, subtitle: string): void {
const heading = container.createDiv({ cls: "smart-review-section-heading" });
heading.createEl("h2", { text: title });
@ -543,6 +629,26 @@ function getDrilldownRows(
.sort((left, right) => right.reviewedAt.localeCompare(left.reviewedAt));
}
function getHeatmapRows(selection: HeatmapSelection, analytics: SmartReviewAnalytics): Array<ReviewHistoryDetail | NoteCreationDetail> {
if (selection.kind === "review") {
return analytics.details.reviewHistory
.filter((entry) => entry.reviewedAt === selection.date)
.sort((left, right) => left.title.localeCompare(right.title));
}
return analytics.details.noteCreation
.filter((entry) => entry.date === selection.date)
.sort((left, right) => left.title.localeCompare(right.title));
}
function getHeatmapDrilldownTitle(locale: SmartReviewLocale, selection: HeatmapSelection): string {
if (selection.kind === "review") {
return t(locale, "reviewDayDetail", { date: selection.date });
}
return t(locale, "creationDayDetail", { date: selection.date });
}
function getDistributionDisplayName(locale: SmartReviewLocale, name: string): string {
return name === UNCATEGORIZED_DOMAIN ? t(locale, "uncategorized") : name;
}

View file

@ -409,20 +409,21 @@
}
.smart-review-heatmap-viewport {
--smart-review-heatmap-cell: clamp(10px, calc((100vw - 520px) / 68), 18px);
--smart-review-heatmap-gap: 4px;
--smart-review-heatmap-gap: clamp(2px, 0.22vw, 4px);
--smart-review-heatmap-row: clamp(8px, 0.72vw, 14px);
width: 100%;
max-width: 100%;
overflow-x: auto;
overflow: visible;
padding-bottom: 2px;
}
.smart-review-heatmap-months,
.smart-review-heatmap {
display: grid;
grid-template-columns: repeat(53, minmax(var(--smart-review-heatmap-cell), 1fr));
grid-template-columns: repeat(53, minmax(0, 1fr));
gap: var(--smart-review-heatmap-gap);
min-width: calc(53 * 10px + 52 * var(--smart-review-heatmap-gap));
width: 100%;
min-width: 0;
}
.smart-review-heatmap-months {
@ -437,33 +438,111 @@
}
.smart-review-heatmap {
grid-template-rows: repeat(7, var(--smart-review-heatmap-cell));
grid-template-rows: repeat(7, var(--smart-review-heatmap-row));
}
.smart-review-heatmap-cell {
position: relative;
display: block;
width: 100%;
height: 100%;
min-width: 10px;
min-height: 10px;
min-width: 0 !important;
min-height: 0 !important;
margin: 0;
padding: 0 !important;
border: 1px solid var(--background-modifier-border);
border-radius: 3px;
background: var(--background-secondary);
background-color: var(--background-secondary) !important;
background-image: none !important;
box-shadow: none !important;
color: transparent;
cursor: pointer;
appearance: none;
line-height: 0;
font-size: 0;
overflow: visible;
}
.smart-review-heatmap-cell:hover,
.smart-review-heatmap-cell:focus-visible,
.smart-review-heatmap-cell-selected {
border-color: var(--interactive-accent);
outline: none;
}
.smart-review-heatmap-cell:focus-visible,
.smart-review-heatmap-cell-selected {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--interactive-accent) 24%, transparent);
}
.smart-review-heatmap-cell::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 8px);
left: 50%;
z-index: 20;
display: block;
min-width: 118px;
padding: 6px 8px;
border: 1px solid var(--smart-review-border);
border-radius: var(--radius-s);
background: var(--background-primary);
color: var(--text-normal);
box-shadow: var(--shadow-s);
font-size: var(--font-ui-smaller);
line-height: 1.35;
text-align: left;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translate(-50%, -2px);
transition: opacity 120ms ease, transform 120ms ease;
white-space: pre-line;
}
.smart-review-heatmap-cell::before {
content: "";
position: absolute;
top: calc(100% + 4px);
left: 50%;
z-index: 21;
width: 8px;
height: 8px;
border-left: 1px solid var(--smart-review-border);
border-top: 1px solid var(--smart-review-border);
background: var(--background-primary);
pointer-events: none;
opacity: 0;
transform: translate(-50%, 0) rotate(45deg);
transition: opacity 120ms ease;
}
.smart-review-heatmap-cell:hover::after,
.smart-review-heatmap-cell:focus-visible::after,
.smart-review-heatmap-cell:hover::before,
.smart-review-heatmap-cell:focus-visible::before {
opacity: 1;
}
.smart-review-heatmap-cell:hover::after,
.smart-review-heatmap-cell:focus-visible::after {
transform: translate(-50%, 0);
}
.smart-review-heatmap-level-1 {
background: color-mix(in srgb, var(--interactive-accent) 20%, var(--smart-review-card-bg));
background-color: color-mix(in srgb, var(--interactive-accent) 20%, var(--smart-review-card-bg)) !important;
}
.smart-review-heatmap-level-2 {
background: color-mix(in srgb, var(--interactive-accent) 38%, var(--smart-review-card-bg));
background-color: color-mix(in srgb, var(--interactive-accent) 38%, var(--smart-review-card-bg)) !important;
}
.smart-review-heatmap-level-3 {
background: color-mix(in srgb, var(--interactive-accent) 58%, var(--smart-review-card-bg));
background-color: color-mix(in srgb, var(--interactive-accent) 58%, var(--smart-review-card-bg)) !important;
}
.smart-review-heatmap-level-4 {
background: color-mix(in srgb, var(--interactive-accent) 78%, var(--smart-review-card-bg));
background-color: color-mix(in srgb, var(--interactive-accent) 78%, var(--smart-review-card-bg)) !important;
}
.smart-review-distributions-grid {

View file

@ -1,5 +1,6 @@
{
"0.1.0": "1.5.0",
"0.1.1": "1.5.0",
"0.2.0": "1.5.0"
"0.2.0": "1.5.0",
"0.2.1": "1.5.0"
}

View file

@ -1,7 +1,7 @@
{
"id": "smart-review",
"name": "Smart Review",
"version": "0.2.0",
"version": "0.2.1",
"minAppVersion": "1.5.0",
"description": "Review center for Properties and YAML frontmatter with spaced feedback, history, and AI payload export.",
"author": "Jayce",

View file

@ -1,6 +1,6 @@
{
"name": "smart-review",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"description": "Smart Review plugin workspace.",
"type": "module",

View file

@ -1,6 +1,6 @@
{
"name": "@smart-review/shared",
"version": "0.2.0",
"version": "0.2.1",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View file

@ -1,5 +1,6 @@
{
"0.1.0": "1.5.0",
"0.1.1": "1.5.0",
"0.2.0": "1.5.0"
"0.2.0": "1.5.0",
"0.2.1": "1.5.0"
}