├── documentation ├── plans │ └── .gitkeep ├── screenshots │ ├── zoom-in.png │ ├── visualizations.png │ ├── life-tracking-grid.png │ ├── customize-granularity.png │ ├── customize-visualizations.png │ ├── select-visualization-type.png │ ├── customize-base-view-settings.png │ ├── data-entry-on-a-set-of-notes.png │ ├── data-entry-on-a-specific-note.png │ └── configure-global-settings-and-presets.png ├── Domain Model.md ├── Business Rules.md ├── Configuration.md └── Architecture.md ├── src ├── app │ ├── commands │ │ ├── .gitkeep │ │ ├── index.ts │ │ └── capture-command.ts │ ├── services │ │ ├── .gitkeep │ │ ├── chart-loader.service.ts │ │ ├── render-cache.service.ts │ │ ├── date-grouping.utils.ts │ │ ├── date-anchor.service.ts │ │ └── property-recognition.service.ts │ ├── settings │ │ └── components │ │ │ └── .gitkeep │ ├── types │ │ ├── common │ │ │ ├── index.ts │ │ │ └── log-level.intf.ts │ │ ├── ui │ │ │ ├── grid-settings.intf.ts │ │ │ ├── card-menu-callback.intf.ts │ │ │ ├── grid-settings-change-callback.intf.ts │ │ │ ├── index.ts │ │ │ └── card-menu-action.intf.ts │ │ ├── view │ │ │ ├── config-getter.intf.ts │ │ │ ├── maximize-callback.intf.ts │ │ │ ├── index.ts │ │ │ ├── get-data-points-callback.intf.ts │ │ │ └── date-anchor.types.ts │ │ ├── visualization │ │ │ ├── animation-state.intf.ts │ │ │ ├── time-granularity.intf.ts │ │ │ ├── visualization-type.intf.ts │ │ │ ├── index.ts │ │ │ ├── visualization-options.intf.ts │ │ │ └── visualization.types.ts │ │ ├── editor │ │ │ ├── dirty-change-callback.intf.ts │ │ │ ├── index.ts │ │ │ ├── property-editor.intf.ts │ │ │ └── property-editor-config.intf.ts │ │ ├── chart │ │ │ ├── chart-click-element.intf.ts │ │ │ ├── chart-type.intf.ts │ │ │ ├── point-tooltip-context.intf.ts │ │ │ ├── cartesian-tooltip-context.intf.ts │ │ │ ├── pie-tooltip-context.intf.ts │ │ │ ├── chart-dataset-config.intf.ts │ │ │ ├── index.ts │ │ │ └── chart-instance.intf.ts │ │ ├── column │ │ │ ├── column-config-callback.intf.ts │ │ │ ├── column-config-result.intf.ts │ │ │ ├── effective-config-result.intf.ts │ │ │ ├── index.ts │ │ │ └── column-config.types.ts │ │ ├── plugin │ │ │ ├── file-provider.intf.ts │ │ │ ├── settings-change-callback.intf.ts │ │ │ ├── settings-change-info.intf.ts │ │ │ ├── capture-context.intf.ts │ │ │ ├── index.ts │ │ │ ├── batch-filter-mode.intf.ts │ │ │ └── plugin-settings.intf.ts │ │ ├── property │ │ │ ├── index.ts │ │ │ └── property-definition.types.ts │ │ └── index.ts │ ├── components │ │ ├── visualizations │ │ │ ├── chart │ │ │ │ └── chart-types.ts │ │ │ └── tag-cloud │ │ │ │ └── tag-cloud-visualization.ts │ │ ├── editing │ │ │ ├── property-editor.ts │ │ │ ├── base-editor.ts │ │ │ ├── date-editor.ts │ │ │ ├── boolean-editor.ts │ │ │ ├── text-editor.ts │ │ │ └── dirty-state.service.ts │ │ └── ui │ │ │ ├── folder-suggest.ts │ │ │ ├── empty-state.ts │ │ │ ├── grid-controls.ts │ │ │ ├── tooltip.ts │ │ │ └── column-config-card.ts │ └── view │ │ ├── grid-view │ │ └── grid-view-options.ts │ │ ├── visualization-config.helper.ts │ │ ├── view-options.ts │ │ ├── column-config.service.ts │ │ └── maximize-state.service.ts ├── assets │ └── buy-me-a-coffee.png ├── main.ts └── utils │ ├── log.utils.ts │ ├── dom.utils.ts │ ├── index.ts │ ├── dom.utils.spec.ts │ ├── log.utils.spec.ts │ └── value.utils.ts ├── CLAUDE.md ├── versions.json ├── .prettierignore ├── .claude └── settings.local.json ├── manifest.json ├── bunfig.toml ├── LICENSE ├── .gitattributes ├── scripts ├── generate-changelog.ts ├── update-version.ts ├── update-version.spec.ts ├── create-release-zip.ts ├── build.spec.ts ├── generate-changelog.spec.ts ├── create-release-zip.spec.ts ├── version-bump.ts ├── version-bump.spec.ts └── build.ts ├── .gitignore ├── tsconfig.json ├── package.json ├── CONTRIBUTING.md └── DEVELOPMENT.md /documentation/plans/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/commands/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/services/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | Read @./AGENTS.md 2 | -------------------------------------------------------------------------------- /src/app/settings/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "1.10.0" 3 | } 4 | -------------------------------------------------------------------------------- /src/app/types/common/index.ts: -------------------------------------------------------------------------------- 1 | export type { LogLevel } from './log-level.intf' 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | CHANGELOG.md 4 | .claude/ 5 | bun.lockb 6 | -------------------------------------------------------------------------------- /src/assets/buy-me-a-coffee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/src/assets/buy-me-a-coffee.png -------------------------------------------------------------------------------- /src/app/types/common/log-level.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Log level type 3 | */ 4 | export type LogLevel = 'debug' | 'info' | 'warn' | 'error' 5 | -------------------------------------------------------------------------------- /src/app/types/ui/grid-settings.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Grid layout settings 3 | */ 4 | export interface GridSettings { 5 | columns: number 6 | } 7 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { LifeTrackerPlugin } from './app/plugin' 2 | 3 | // noinspection JSUnusedGlobalSymbols 4 | export default LifeTrackerPlugin 5 | -------------------------------------------------------------------------------- /documentation/screenshots/zoom-in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/documentation/screenshots/zoom-in.png -------------------------------------------------------------------------------- /src/app/types/view/config-getter.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type for getting configuration values 3 | */ 4 | export type ConfigGetter = (key: string) => unknown 5 | -------------------------------------------------------------------------------- /documentation/screenshots/visualizations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/documentation/screenshots/visualizations.png -------------------------------------------------------------------------------- /documentation/screenshots/life-tracking-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/documentation/screenshots/life-tracking-grid.png -------------------------------------------------------------------------------- /src/app/types/visualization/animation-state.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Animation state type for visualizations 3 | */ 4 | export type AnimationState = 'idle' | 'playing' | 'paused' 5 | -------------------------------------------------------------------------------- /documentation/screenshots/customize-granularity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/documentation/screenshots/customize-granularity.png -------------------------------------------------------------------------------- /documentation/screenshots/customize-visualizations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/documentation/screenshots/customize-visualizations.png -------------------------------------------------------------------------------- /documentation/screenshots/select-visualization-type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/documentation/screenshots/select-visualization-type.png -------------------------------------------------------------------------------- /src/app/types/editor/dirty-change-callback.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Callback for dirty state changes 3 | */ 4 | export type DirtyChangeCallback = (entryId: string, isDirty: boolean) => void 5 | -------------------------------------------------------------------------------- /src/app/types/chart/chart-click-element.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Chart click element type 3 | */ 4 | export interface ChartClickElement { 5 | index: number 6 | datasetIndex: number 7 | } 8 | -------------------------------------------------------------------------------- /documentation/screenshots/customize-base-view-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/documentation/screenshots/customize-base-view-settings.png -------------------------------------------------------------------------------- /documentation/screenshots/data-entry-on-a-set-of-notes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/documentation/screenshots/data-entry-on-a-set-of-notes.png -------------------------------------------------------------------------------- /documentation/screenshots/data-entry-on-a-specific-note.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/documentation/screenshots/data-entry-on-a-specific-note.png -------------------------------------------------------------------------------- /src/app/types/chart/chart-type.intf.ts: -------------------------------------------------------------------------------- 1 | import type { ChartJsType } from '../visualization/visualization.types' 2 | 3 | /** 4 | * Chart.js type alias 5 | */ 6 | export type ChartType = ChartJsType 7 | -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(bun:*)", 5 | "Bash(cat:*)", 6 | "Bash(wc:*)", 7 | "Bash(ls:*)" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /documentation/screenshots/configure-global-settings-and-presets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsebastien/obsidian-life-tracker-base-view/HEAD/documentation/screenshots/configure-global-settings-and-presets.png -------------------------------------------------------------------------------- /src/app/types/chart/point-tooltip-context.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tooltip callback context for scatter/bubble charts 3 | */ 4 | export interface PointTooltipContext { 5 | parsed: { x?: number; y?: number } 6 | raw?: { r?: number } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/types/editor/index.ts: -------------------------------------------------------------------------------- 1 | export type { PropertyEditor } from './property-editor.intf' 2 | export type { PropertyEditorConfig } from './property-editor-config.intf' 3 | export type { DirtyChangeCallback } from './dirty-change-callback.intf' 4 | -------------------------------------------------------------------------------- /src/app/types/ui/card-menu-callback.intf.ts: -------------------------------------------------------------------------------- 1 | import type { CardMenuAction } from './card-menu-action.intf' 2 | 3 | /** 4 | * Callback when a menu action is selected 5 | */ 6 | export type CardMenuCallback = (action: CardMenuAction) => void 7 | -------------------------------------------------------------------------------- /src/app/types/ui/grid-settings-change-callback.intf.ts: -------------------------------------------------------------------------------- 1 | import type { GridSettings } from './grid-settings.intf' 2 | 3 | /** 4 | * Callback when grid settings change 5 | */ 6 | export type GridSettingsChangeCallback = (settings: GridSettings) => void 7 | -------------------------------------------------------------------------------- /src/app/types/view/maximize-callback.intf.ts: -------------------------------------------------------------------------------- 1 | import type { BasesPropertyId } from 'obsidian' 2 | 3 | /** 4 | * Callback type for maximize toggle events 5 | */ 6 | export type MaximizeCallback = (propertyId: BasesPropertyId, maximize: boolean) => void 7 | -------------------------------------------------------------------------------- /src/app/types/chart/cartesian-tooltip-context.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tooltip callback context for cartesian charts (line/bar/area) 3 | */ 4 | export interface CartesianTooltipContext { 5 | dataset: { label?: string } 6 | parsed: { y: number | null } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/types/column/column-config-callback.intf.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnConfigResult } from './column-config-result.intf' 2 | 3 | /** 4 | * Callback when user completes column configuration 5 | */ 6 | export type ColumnConfigCallback = (result: ColumnConfigResult) => void 7 | -------------------------------------------------------------------------------- /src/app/types/visualization/time-granularity.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Time granularity for visualizations 3 | */ 4 | export enum TimeGranularity { 5 | Daily = 'daily', 6 | Weekly = 'weekly', 7 | Monthly = 'monthly', 8 | Quarterly = 'quarterly', 9 | Yearly = 'yearly' 10 | } 11 | -------------------------------------------------------------------------------- /src/app/types/ui/index.ts: -------------------------------------------------------------------------------- 1 | export type { GridSettings } from './grid-settings.intf' 2 | export type { GridSettingsChangeCallback } from './grid-settings-change-callback.intf' 3 | export type { CardMenuAction } from './card-menu-action.intf' 4 | export type { CardMenuCallback } from './card-menu-callback.intf' 5 | -------------------------------------------------------------------------------- /src/app/commands/index.ts: -------------------------------------------------------------------------------- 1 | import type { LifeTrackerPlugin } from '../plugin' 2 | import { registerCaptureCommand } from './capture-command' 3 | 4 | /** 5 | * Register all plugin commands 6 | */ 7 | export function registerCommands(plugin: LifeTrackerPlugin): void { 8 | registerCaptureCommand(plugin) 9 | } 10 | -------------------------------------------------------------------------------- /src/app/types/chart/pie-tooltip-context.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tooltip callback context for pie/doughnut/polarArea charts. 3 | * For pie/doughnut, parsed is a number. 4 | * For polarArea, parsed is an object { r: number }. 5 | */ 6 | export interface PieTooltipContext { 7 | label: string 8 | parsed: number | { r: number } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/components/visualizations/chart/chart-types.ts: -------------------------------------------------------------------------------- 1 | // Re-export all chart types from the centralized types folder 2 | export type { 3 | ChartType, 4 | ChartDatasetConfig, 5 | ChartInstance, 6 | ChartClickElement, 7 | PieTooltipContext, 8 | CartesianTooltipContext, 9 | PointTooltipContext 10 | } from '../../../types' 11 | -------------------------------------------------------------------------------- /src/app/types/plugin/file-provider.intf.ts: -------------------------------------------------------------------------------- 1 | import type { TFile } from 'obsidian' 2 | import type { BatchFilterMode } from './batch-filter-mode.intf' 3 | 4 | /** 5 | * Interface for views that can provide files for batch capture 6 | */ 7 | export interface FileProvider { 8 | getFiles(): TFile[] 9 | getFilterMode(): BatchFilterMode 10 | } 11 | -------------------------------------------------------------------------------- /src/app/types/chart/chart-dataset-config.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Chart.js dataset configuration 3 | */ 4 | export interface ChartDatasetConfig { 5 | label: string 6 | data: (number | null)[] 7 | backgroundColor?: string | string[] 8 | borderColor?: string 9 | borderWidth?: number 10 | tension?: number 11 | fill?: boolean 12 | } 13 | -------------------------------------------------------------------------------- /src/app/types/view/index.ts: -------------------------------------------------------------------------------- 1 | export type { ConfigGetter } from './config-getter.intf' 2 | export type { MaximizeCallback } from './maximize-callback.intf' 3 | export type { GetDataPointsCallback } from './get-data-points-callback.intf' 4 | export type { 5 | DateAnchorSource, 6 | DateAnchorConfig, 7 | ResolvedDateAnchor, 8 | DatePattern 9 | } from './date-anchor.types' 10 | -------------------------------------------------------------------------------- /src/app/types/column/column-config-result.intf.ts: -------------------------------------------------------------------------------- 1 | import type { VisualizationType } from '../visualization/visualization-type.intf' 2 | import type { ScaleConfig } from './column-config.types' 3 | 4 | /** 5 | * Configuration result from the column config card 6 | */ 7 | export interface ColumnConfigResult { 8 | visualizationType: VisualizationType 9 | scale?: ScaleConfig 10 | } 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "life-tracker", 3 | "name": "Life Tracker", 4 | "description": "Capture and visualize the data that matters in your life.", 5 | "version": "1.7.0", 6 | "minAppVersion": "1.10.0", 7 | "isDesktopOnly": false, 8 | "author": "Sébastien Dubois", 9 | "authorUrl": "https://dsebastien.net", 10 | "fundingUrl": "https://www.buymeacoffee.com/dsebastien" 11 | } 12 | -------------------------------------------------------------------------------- /src/app/types/column/effective-config-result.intf.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnVisualizationConfig } from './column-config.types' 2 | 3 | /** 4 | * Result of getting effective configuration for a column. 5 | * When getEffectiveConfig returns a result (not null), config is always defined. 6 | */ 7 | export interface EffectiveConfigResult { 8 | config: ColumnVisualizationConfig 9 | isFromPreset: boolean 10 | } 11 | -------------------------------------------------------------------------------- /src/app/types/view/get-data-points-callback.intf.ts: -------------------------------------------------------------------------------- 1 | import type { BasesPropertyId } from 'obsidian' 2 | import type { VisualizationDataPoint } from '../visualization/visualization.types' 3 | 4 | /** 5 | * Callback type for getting data points for re-render 6 | */ 7 | export type GetDataPointsCallback = ( 8 | propertyId: BasesPropertyId, 9 | propertyDisplayName: string 10 | ) => VisualizationDataPoint[] 11 | -------------------------------------------------------------------------------- /src/app/types/column/index.ts: -------------------------------------------------------------------------------- 1 | export { SCALE_SUPPORTED_TYPES, supportsScale } from './column-config.types' 2 | export type { ScaleConfig, ColumnVisualizationConfig, ColumnConfigMap } from './column-config.types' 3 | export type { ColumnConfigResult } from './column-config-result.intf' 4 | export type { ColumnConfigCallback } from './column-config-callback.intf' 5 | export type { EffectiveConfigResult } from './effective-config-result.intf' 6 | -------------------------------------------------------------------------------- /src/app/types/chart/index.ts: -------------------------------------------------------------------------------- 1 | export type { ChartType } from './chart-type.intf' 2 | export type { ChartDatasetConfig } from './chart-dataset-config.intf' 3 | export type { ChartInstance } from './chart-instance.intf' 4 | export type { ChartClickElement } from './chart-click-element.intf' 5 | export type { CartesianTooltipContext } from './cartesian-tooltip-context.intf' 6 | export type { PieTooltipContext } from './pie-tooltip-context.intf' 7 | export type { PointTooltipContext } from './point-tooltip-context.intf' 8 | -------------------------------------------------------------------------------- /src/app/types/ui/card-menu-action.intf.ts: -------------------------------------------------------------------------------- 1 | import type { VisualizationType } from '../visualization/visualization-type.intf' 2 | import type { ScaleConfig } from '../column/column-config.types' 3 | 4 | /** 5 | * Menu action types for card context menu 6 | */ 7 | export type CardMenuAction = 8 | | { type: 'changeVisualization'; visualizationType: VisualizationType } 9 | | { type: 'configureScale'; scale: ScaleConfig | undefined } 10 | | { type: 'resetConfig' } 11 | | { type: 'toggleMaximize' } 12 | -------------------------------------------------------------------------------- /src/app/types/plugin/settings-change-callback.intf.ts: -------------------------------------------------------------------------------- 1 | import type { PluginSettings } from './plugin-settings.intf' 2 | import type { SettingsChangeInfo } from './settings-change-info.intf' 3 | 4 | /** 5 | * Callback type for settings change listeners 6 | * @param settings - The updated settings 7 | * @param changeInfo - Information about what changed (enables targeted updates) 8 | */ 9 | export type SettingsChangeCallback = ( 10 | settings: PluginSettings, 11 | changeInfo: SettingsChangeInfo 12 | ) => void 13 | -------------------------------------------------------------------------------- /src/app/types/plugin/settings-change-info.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes what changed in settings to enable targeted updates 3 | */ 4 | export type SettingsChangeInfo = 5 | | { type: 'preset-updated'; presetId: string } 6 | | { type: 'preset-added'; presetId: string } 7 | | { type: 'preset-deleted'; presetId: string } 8 | | { type: 'animation-duration-changed' } 9 | | { type: 'property-definitions-changed' } 10 | | { type: 'confetti-setting-changed' } 11 | | { type: 'full' } // Generic change requiring full refresh 12 | -------------------------------------------------------------------------------- /src/app/types/plugin/capture-context.intf.ts: -------------------------------------------------------------------------------- 1 | import type { TFile } from 'obsidian' 2 | import type { PropertyDefinition } from '../property/property-definition.types' 3 | 4 | /** 5 | * Context for the capture command 6 | */ 7 | export interface CaptureContext { 8 | /** Files to process (single file or batch from view) */ 9 | files: TFile[] 10 | /** Property definitions that apply to the context */ 11 | definitions: PropertyDefinition[] 12 | /** Whether this is batch mode (multiple files) */ 13 | isBatchMode: boolean 14 | } 15 | -------------------------------------------------------------------------------- /src/app/types/chart/chart-instance.intf.ts: -------------------------------------------------------------------------------- 1 | import type { ChartDatasetConfig } from './chart-dataset-config.intf' 2 | 3 | /** 4 | * Internal chart instance interface 5 | */ 6 | export interface ChartInstance { 7 | destroy: () => void 8 | update: (mode?: string) => void 9 | resize: () => void 10 | reset: () => void 11 | data: { 12 | labels: string[] 13 | datasets: ChartDatasetConfig[] 14 | } 15 | options: { 16 | animation?: { 17 | duration?: number 18 | easing?: string 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/types/visualization/visualization-type.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Visualization type for rendering data 3 | */ 4 | export enum VisualizationType { 5 | Heatmap = 'heatmap', 6 | LineChart = 'line-chart', 7 | BarChart = 'bar-chart', 8 | AreaChart = 'area-chart', 9 | PieChart = 'pie-chart', 10 | DoughnutChart = 'doughnut-chart', 11 | RadarChart = 'radar-chart', 12 | PolarAreaChart = 'polar-area-chart', 13 | ScatterChart = 'scatter-chart', 14 | BubbleChart = 'bubble-chart', 15 | TagCloud = 'tag-cloud', 16 | Timeline = 'timeline' 17 | } 18 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | telemetry = false 2 | logLevel = "debug" # "debug" | "warn" | "error" 3 | 4 | [console] 5 | depth = 2 6 | 7 | [install] 8 | auto = "auto" 9 | exact = true 10 | optional = false 11 | # Only install package versions published at least 3 days ago 12 | minimumReleaseAge = 259200 13 | # These packages will bypass the 3-day minimum age requirement 14 | minimumReleaseAgeExcludes = ["@types/bun", "typescript"] 15 | 16 | [run] 17 | # equivalent to `bun --bun` for all `bun run` commands 18 | bun = true 19 | 20 | [test] 21 | coverage = false 22 | onlyFailures = true 23 | 24 | [test.reporter] 25 | dots = true 26 | 27 | -------------------------------------------------------------------------------- /src/app/types/property/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | PROPERTY_TYPES, 3 | PROPERTY_TYPE_LABELS, 4 | MAPPING_TYPE_LABELS, 5 | createDefaultPropertyDefinition, 6 | createDefaultMapping 7 | } from './property-definition.types' 8 | export type { 9 | ObsidianPropertyType, 10 | PropertyType, 11 | MappingType, 12 | NumberRange, 13 | PropertyDefaultValue, 14 | PropertyAllowedValues, 15 | PropertyDefinition, 16 | ValidationResult, 17 | Mapping, 18 | PropertyIssue 19 | } from './property-definition.types' 20 | export { getPropertyDisplayLabel } from './property-definition.types' 21 | -------------------------------------------------------------------------------- /src/app/types/editor/property-editor.intf.ts: -------------------------------------------------------------------------------- 1 | import type { ValidationResult } from '../property/property-definition.types' 2 | 3 | /** 4 | * Interface for property editors 5 | */ 6 | export interface PropertyEditor { 7 | /** Render the editor into a container */ 8 | render(container: HTMLElement): void 9 | /** Get current value */ 10 | getValue(): unknown 11 | /** Set value programmatically */ 12 | setValue(value: unknown): void 13 | /** Focus the editor */ 14 | focus(): void 15 | /** Validate current value */ 16 | validate(): ValidationResult 17 | /** Clean up resources */ 18 | destroy(): void 19 | } 20 | -------------------------------------------------------------------------------- /src/app/types/plugin/index.ts: -------------------------------------------------------------------------------- 1 | export { DEFAULT_SETTINGS } from './plugin-settings.intf' 2 | export type { PluginSettings, PropertyVisualizationPreset } from './plugin-settings.intf' 3 | export type { SettingsChangeCallback } from './settings-change-callback.intf' 4 | export type { SettingsChangeInfo } from './settings-change-info.intf' 5 | export type { FileProvider } from './file-provider.intf' 6 | export { DEFAULT_BATCH_FILTER_MODE, BATCH_FILTER_MODE_OPTIONS } from './batch-filter-mode.intf' 7 | export type { BatchFilterMode } from './batch-filter-mode.intf' 8 | export { getBatchFilterModeLabel } from './batch-filter-mode.intf' 9 | export type { CaptureContext } from './capture-context.intf' 10 | -------------------------------------------------------------------------------- /src/app/types/editor/property-editor-config.intf.ts: -------------------------------------------------------------------------------- 1 | import type { PropertyDefinition } from '../property/property-definition.types' 2 | 3 | /** 4 | * Configuration for creating a property editor 5 | */ 6 | export interface PropertyEditorConfig { 7 | /** Property definition */ 8 | definition: PropertyDefinition 9 | /** Current value */ 10 | value: unknown 11 | /** Called when value changes */ 12 | onChange: (value: unknown) => void 13 | /** Called when editing is committed (blur) - for saving */ 14 | onCommit?: () => void 15 | /** Called when Enter key is pressed - for navigation */ 16 | onEnterKey?: () => void 17 | /** Compact mode for table cells */ 18 | compact?: boolean 19 | } 20 | -------------------------------------------------------------------------------- /src/app/types/plugin/batch-filter-mode.intf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Filter mode for batch capture - determines when a file is considered "complete" 3 | */ 4 | export type BatchFilterMode = 'required' | 'all' | 'never' 5 | 6 | /** 7 | * Default filter mode 8 | */ 9 | export const DEFAULT_BATCH_FILTER_MODE: BatchFilterMode = 'required' 10 | 11 | /** 12 | * Options for the batch filter mode dropdown 13 | */ 14 | export const BATCH_FILTER_MODE_OPTIONS: Record = { 15 | required: 'All required properties filled', 16 | all: 'All properties filled', 17 | never: 'Never' 18 | } 19 | 20 | /** 21 | * Get the display label for a filter mode 22 | */ 23 | export function getBatchFilterModeLabel(mode: BatchFilterMode): string { 24 | return BATCH_FILTER_MODE_OPTIONS[mode] 25 | } 26 | -------------------------------------------------------------------------------- /src/app/view/grid-view/grid-view-options.ts: -------------------------------------------------------------------------------- 1 | import type { ViewOption } from 'obsidian' 2 | import { BATCH_FILTER_MODE_OPTIONS, DEFAULT_BATCH_FILTER_MODE } from '../../types' 3 | 4 | /** 5 | * Get view options for Grid View configuration 6 | */ 7 | export function getGridViewOptions(): ViewOption[] { 8 | return [ 9 | // Filtering options 10 | { 11 | type: 'group', 12 | displayName: 'Filtering', 13 | items: [ 14 | { 15 | type: 'dropdown', 16 | key: 'hideNotesWhen', 17 | displayName: 'Hide notes when', 18 | default: DEFAULT_BATCH_FILTER_MODE, 19 | options: BATCH_FILTER_MODE_OPTIONS 20 | } 21 | ] 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/log.utils.ts: -------------------------------------------------------------------------------- 1 | import * as pluginManifest from '../../manifest.json' 2 | import type { LogLevel } from '../app/types' 3 | 4 | const LOG_PREFIX = `${pluginManifest.name}:` 5 | 6 | /** 7 | * Log a message 8 | * @param message 9 | * @param level 10 | * @param data 11 | */ 12 | export const log = (message: string, level?: LogLevel, ...data: unknown[]): void => { 13 | const logMessage = `${LOG_PREFIX} ${message}` 14 | switch (level) { 15 | case 'debug': 16 | case 'info': 17 | // Obsidian disallows console.log and console.info, use debug for both 18 | console.debug(logMessage, data) 19 | break 20 | case 'warn': 21 | console.warn(logMessage, data) 22 | break 23 | case 'error': 24 | console.error(logMessage, data) 25 | break 26 | default: 27 | console.debug(logMessage, data) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/types/view/date-anchor.types.ts: -------------------------------------------------------------------------------- 1 | import type { BasesPropertyId } from 'obsidian' 2 | import type { TimeGranularity } from '../visualization/time-granularity.intf' 3 | 4 | /** 5 | * Source for date anchor extraction 6 | */ 7 | export type DateAnchorSource = 8 | | { type: 'filename'; pattern: string } 9 | | { type: 'property'; propertyId: BasesPropertyId } 10 | | { type: 'file-metadata'; field: 'ctime' | 'mtime' } 11 | 12 | /** 13 | * Configuration for date anchor resolution 14 | */ 15 | export interface DateAnchorConfig { 16 | source: DateAnchorSource 17 | priority: number 18 | } 19 | 20 | /** 21 | * Resolved date anchor with confidence level 22 | */ 23 | export interface ResolvedDateAnchor { 24 | date: Date 25 | source: DateAnchorSource 26 | confidence: 'high' | 'medium' | 'low' 27 | } 28 | 29 | /** 30 | * Date pattern for filename matching 31 | */ 32 | export interface DatePattern { 33 | regex: RegExp 34 | granularity: TimeGranularity 35 | parser: (match: RegExpMatchArray) => Date | null 36 | } 37 | -------------------------------------------------------------------------------- /src/app/types/visualization/index.ts: -------------------------------------------------------------------------------- 1 | export { VisualizationType } from './visualization-type.intf' 2 | export { TimeGranularity } from './time-granularity.intf' 3 | export type { AnimationState } from './animation-state.intf' 4 | export type { 5 | VisualizationDataPoint, 6 | HeatmapData, 7 | HeatmapCell, 8 | ChartData, 9 | ChartDataset, 10 | PieChartData, 11 | ScatterPoint, 12 | BubblePoint, 13 | ScatterChartData, 14 | BubbleChartData, 15 | TagCloudData, 16 | TagCloudItem, 17 | TimelineData, 18 | TimelinePoint, 19 | HeatmapColorScheme, 20 | VisualizationConfig, 21 | HeatmapConfig, 22 | ChartJsType, 23 | ChartConfig, 24 | TagCloudConfig 25 | } from './visualization.types' 26 | export { 27 | CONFIG_CARD_VISUALIZATION_OPTIONS, 28 | CONTEXT_MENU_VISUALIZATION_OPTIONS, 29 | SCALE_PRESETS, 30 | SETTINGS_TAB_VISUALIZATION_OPTIONS, 31 | SCALE_PRESETS_RECORD 32 | } from './visualization-options.intf' 33 | export type { 34 | ContextMenuVisualizationOption, 35 | ConfigCardVisualizationOption, 36 | ScalePreset 37 | } from './visualization-options.intf' 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sebastien Dubois 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/components/editing/property-editor.ts: -------------------------------------------------------------------------------- 1 | import type { PropertyEditor, PropertyEditorConfig } from '../../types' 2 | import { TextEditor } from './text-editor' 3 | import { NumberEditor } from './number-editor' 4 | import { BooleanEditor } from './boolean-editor' 5 | import { DateEditor } from './date-editor' 6 | import { ListEditor } from './list-editor' 7 | export { BasePropertyEditor } from './base-editor' 8 | 9 | /** 10 | * Factory function to create the appropriate editor for a property type 11 | */ 12 | export function createPropertyEditor(config: PropertyEditorConfig): PropertyEditor { 13 | switch (config.definition.type) { 14 | case 'text': 15 | return new TextEditor(config) 16 | case 'number': 17 | return new NumberEditor(config) 18 | case 'checkbox': 19 | return new BooleanEditor(config) 20 | case 'date': 21 | case 'datetime': 22 | return new DateEditor(config) 23 | case 'list': 24 | case 'tags': 25 | return new ListEditor(config) 26 | default: 27 | // Fallback to text editor for unknown types 28 | return new TextEditor(config) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/components/editing/base-editor.ts: -------------------------------------------------------------------------------- 1 | import type { ValidationResult, PropertyEditorConfig, PropertyEditor } from '../../types' 2 | 3 | /** 4 | * Base class for property editors with common functionality 5 | */ 6 | export abstract class BasePropertyEditor implements PropertyEditor { 7 | protected containerEl: HTMLElement | null = null 8 | 9 | constructor(protected config: PropertyEditorConfig) {} 10 | 11 | abstract render(container: HTMLElement): void 12 | abstract getValue(): unknown 13 | abstract setValue(value: unknown): void 14 | abstract focus(): void 15 | abstract validate(): ValidationResult 16 | 17 | destroy(): void { 18 | if (this.containerEl) { 19 | this.containerEl.empty() 20 | } 21 | } 22 | 23 | protected notifyChange(value: unknown): void { 24 | this.config.onChange(value) 25 | } 26 | 27 | protected notifyCommit(): void { 28 | this.config.onCommit?.() 29 | } 30 | 31 | protected notifyEnterKey(): void { 32 | this.config.onEnterKey?.() 33 | } 34 | 35 | protected getDisplayLabel(): string { 36 | return this.config.definition.displayName || this.config.definition.name 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/components/ui/folder-suggest.ts: -------------------------------------------------------------------------------- 1 | import { AbstractInputSuggest, type App, type TAbstractFile, TFolder } from 'obsidian' 2 | 3 | /** 4 | * Autocomplete suggester for vault folders. 5 | * Attaches to an input element and shows folder suggestions as the user types. 6 | */ 7 | export class FolderSuggest extends AbstractInputSuggest { 8 | constructor( 9 | private inputEl: HTMLInputElement, 10 | app: App 11 | ) { 12 | super(app, inputEl) 13 | } 14 | 15 | /** 16 | * Get folder suggestions matching the input string 17 | */ 18 | getSuggestions(inputStr: string): TFolder[] { 19 | const abstractFiles = this.app.vault.getAllLoadedFiles() 20 | const folders: TFolder[] = [] 21 | const lowerCaseInputStr = inputStr.toLowerCase() 22 | 23 | abstractFiles.forEach((file: TAbstractFile) => { 24 | if (file instanceof TFolder && file.path.toLowerCase().contains(lowerCaseInputStr)) { 25 | folders.push(file) 26 | } 27 | }) 28 | 29 | return folders 30 | } 31 | 32 | override renderSuggestion(folder: TFolder, el: HTMLElement): void { 33 | el.setText(folder.path) 34 | } 35 | 36 | override selectSuggestion(folder: TFolder): void { 37 | this.inputEl.value = folder.path 38 | this.inputEl.trigger('input') 39 | this.close() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto eol=lf 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | 19 | # Diff hints 20 | *.css diff=css 21 | *.html diff=html 22 | *.md diff=markdown 23 | *.ts diff=typescript 24 | *.json diff=json 25 | 26 | # These files are text and should be normalized (Convert crlf => lf) 27 | *.bash text eol=lf 28 | *.css text eol=lf 29 | *.htm text eol=lf diff=html 30 | *.html text eol=lf diff=html 31 | *.js text eol=lf 32 | *.json text eol=lf 33 | *.map text eol=lf 34 | *.md text eol=lf 35 | *.scss text eol=lf 36 | *.sh text eol=lf 37 | *.svg text eol=lf 38 | *.ts text eol=lf 39 | *.txt text eol=lf 40 | *.xml text eol=lf 41 | 42 | # Don't include in releases 43 | .github/ export-ignore 44 | .husky/ export-ignore 45 | CHANGELOG.md export-ignore 46 | prettier.config.cjs export-ignore 47 | eslint.config.ts export-ignore 48 | commitlint.config.ts export-ignore 49 | .cz-config.cjs export-ignore 50 | .editorconfig export-ignore 51 | .nvmrc export-ignore 52 | 53 | # Windows specific files that need CRLF 54 | *.bat text eol=crlf 55 | *.cmd text eol=crlf 56 | -------------------------------------------------------------------------------- /scripts/generate-changelog.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates or updates CHANGELOG.md using conventional-changelog. 3 | * Usage: bun scripts/generate-changelog.ts 4 | */ 5 | 6 | import { $ } from 'bun' 7 | 8 | export async function generateChangelog(): Promise { 9 | // Generate changelog and capture output 10 | const result = 11 | await $`bunx conventional-changelog -p conventionalcommits -i CHANGELOG.md -s -r 0`.text() 12 | return result 13 | } 14 | 15 | export async function getLatestChangelogEntry(): Promise { 16 | const changelogFile = Bun.file('CHANGELOG.md') 17 | if (!(await changelogFile.exists())) { 18 | return '' 19 | } 20 | 21 | const content = await changelogFile.text() 22 | // Extract the latest version section (between first and second ## headers) 23 | const sections = content.split(/^## /m) 24 | if (sections.length < 2) { 25 | return content 26 | } 27 | // Return the first version section (sections[0] is content before first ##) 28 | return '## ' + (sections[1] ?? '') 29 | } 30 | 31 | // Only run if executed directly 32 | if (import.meta.main) { 33 | console.log('Generating changelog...') 34 | await generateChangelog() 35 | console.log('Changelog updated successfully.') 36 | 37 | const latestEntry = await getLatestChangelogEntry() 38 | console.log('\n--- Latest changelog entry ---') 39 | console.log(latestEntry) 40 | } 41 | -------------------------------------------------------------------------------- /scripts/update-version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Updates the version in package.json to the specified version. 3 | * Usage: bun scripts/update-version.ts 4 | * Version can optionally have a 'v' prefix which will be stripped. 5 | */ 6 | 7 | const VERSION_REGEX = /^v?(\d+\.\d+\.\d+)$/ 8 | 9 | export function parseVersion(input: string): string { 10 | const match = input.match(VERSION_REGEX) 11 | if (!match?.[1]) { 12 | throw new Error(`Invalid version format: "${input}". Expected format: x.y.z or vx.y.z`) 13 | } 14 | return match[1] 15 | } 16 | 17 | export async function updatePackageVersion(version: string): Promise { 18 | const packageFile = Bun.file('package.json') 19 | const packageJson = await packageFile.json() 20 | packageJson.version = version 21 | await Bun.write(packageFile, JSON.stringify(packageJson, null, 4) + '\n') 22 | console.log(`Updated package.json version to ${version}`) 23 | } 24 | 25 | // Only run if executed directly 26 | if (import.meta.main) { 27 | const version = process.argv[2] 28 | if (!version) { 29 | console.error('Usage: bun scripts/update-version.ts ') 30 | console.error('Example: bun scripts/update-version.ts 1.2.3') 31 | console.error('Example: bun scripts/update-version.ts v1.2.3') 32 | process.exit(1) 33 | } 34 | 35 | const parsedVersion = parseVersion(version) 36 | await updatePackageVersion(parsedVersion) 37 | } 38 | -------------------------------------------------------------------------------- /scripts/update-version.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'bun:test' 2 | import { parseVersion } from './update-version' 3 | 4 | describe('parseVersion', () => { 5 | test('parses version without prefix', () => { 6 | expect(parseVersion('1.2.3')).toBe('1.2.3') 7 | }) 8 | 9 | test('parses version with v prefix', () => { 10 | expect(parseVersion('v1.2.3')).toBe('1.2.3') 11 | }) 12 | 13 | test('parses version with leading zeros', () => { 14 | expect(parseVersion('0.0.1')).toBe('0.0.1') 15 | }) 16 | 17 | test('parses version with large numbers', () => { 18 | expect(parseVersion('10.20.30')).toBe('10.20.30') 19 | }) 20 | 21 | test('throws on invalid format - missing parts', () => { 22 | expect(() => parseVersion('1.2')).toThrow('Invalid version format') 23 | }) 24 | 25 | test('throws on invalid format - extra parts', () => { 26 | expect(() => parseVersion('1.2.3.4')).toThrow('Invalid version format') 27 | }) 28 | 29 | test('throws on invalid format - non-numeric', () => { 30 | expect(() => parseVersion('1.2.x')).toThrow('Invalid version format') 31 | }) 32 | 33 | test('throws on invalid format - empty string', () => { 34 | expect(() => parseVersion('')).toThrow('Invalid version format') 35 | }) 36 | 37 | test('throws on invalid prefix', () => { 38 | expect(() => parseVersion('version1.2.3')).toThrow('Invalid version format') 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Editors 4 | .vscode/* 5 | !.vscode/settings.json 6 | !.vscode/tasks.json 7 | !.vscode/launch.json 8 | !.vscode/extensions.json 9 | *.iml 10 | *.ipr 11 | *.iws 12 | .idea 13 | *.sw? 14 | *.swo 15 | *~ 16 | .netrwhist 17 | 18 | # Dependencies 19 | node_modules 20 | 21 | # Don't include the compiled main.js file in the repo. 22 | # It should be uploaded to GitHub releases instead. 23 | main.js 24 | 25 | # Don't include the compiled styles.css file in the repo. 26 | # It should be uploaded to GitHub releases instead. 27 | styles.css 28 | 29 | # Exclude sourcemaps 30 | *.map 31 | 32 | # Obsidian 33 | data.json 34 | 35 | # System Files 36 | .DS_Store 37 | .AppleDouble 38 | .LSOverride 39 | Thumbs.db 40 | ehthumbs.db 41 | Desktop.ini 42 | .fuse_hidden* 43 | .nfs* 44 | 45 | # Build output 46 | dist/ 47 | out/ 48 | out-tsc/ 49 | build/ 50 | 51 | # Code coverage 52 | coverage/ 53 | *.lcov 54 | .nyc_output/ 55 | 56 | # Logs 57 | logs/ 58 | *.log 59 | npm-debug.log* 60 | yarn-debug.log* 61 | yarn-error.log* 62 | pnpm-debug.log* 63 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 64 | 65 | # Temp files 66 | *.tmp 67 | *.temp 68 | *.bak 69 | *.local 70 | tmp/ 71 | temp/ 72 | 73 | # Dotenv environment variable files 74 | .env 75 | .env.* 76 | !.env.example 77 | 78 | # Test output 79 | test-results/ 80 | playwright-report/ 81 | .vitest/ 82 | 83 | # Debug 84 | .debug/ 85 | *.heapsnapshot 86 | 87 | # Caches 88 | .eslintcache 89 | .cache 90 | *.tsbuildinfo 91 | .turbo/ 92 | .parcel-cache/ 93 | 94 | # Runtime data 95 | pids/ 96 | *.pid 97 | *.seed 98 | *.pid.lock 99 | -------------------------------------------------------------------------------- /scripts/create-release-zip.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a zip file from the dist directory for release. 3 | * Usage: bun scripts/create-release-zip.ts [output-name] 4 | * If output-name is not provided, uses the name from package.json. 5 | */ 6 | 7 | import { readdirSync } from 'node:fs' 8 | import { $ } from 'bun' 9 | 10 | const DIST = 'dist' 11 | 12 | export async function getPackageName(): Promise { 13 | const packageFile = Bun.file('package.json') 14 | const packageJson = await packageFile.json() 15 | return packageJson.name as string 16 | } 17 | 18 | export async function createReleaseZip(outputName?: string): Promise { 19 | const name = outputName ?? (await getPackageName()) 20 | const zipPath = `${DIST}/${name}.zip` 21 | 22 | // Get all files in dist (excluding any existing zip files) 23 | const files = readdirSync(DIST).filter((f) => !f.endsWith('.zip')) 24 | if (files.length === 0) { 25 | throw new Error('dist directory is empty. Run build first.') 26 | } 27 | 28 | // Remove existing zip if present 29 | try { 30 | await $`rm -f ${zipPath}`.quiet() 31 | } catch { 32 | // Ignore if file doesn't exist 33 | } 34 | 35 | // Create zip using system zip command (only include non-zip files) 36 | await $`cd ${DIST} && zip -r ${name}.zip ${files}`.quiet() 37 | 38 | console.log(`Created ${zipPath} with ${files.length} files:`) 39 | for (const file of files) { 40 | console.log(` - ${file}`) 41 | } 42 | 43 | return zipPath 44 | } 45 | 46 | // Only run if executed directly 47 | if (import.meta.main) { 48 | const outputName = process.argv[2] 49 | await createReleaseZip(outputName) 50 | } 51 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": ["DOM", "ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | // Bundler mode 9 | "moduleResolution": "bundler", 10 | "jsx": "react-jsx", 11 | "allowJs": true, 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "esModuleInterop": true, 15 | "importHelpers": true, 16 | "inlineSources": true, 17 | "isolatedModules": true, 18 | "resolveJsonModule": true, 19 | "checkJs": false, 20 | 21 | // Maximum strictness 22 | "strict": true, 23 | "alwaysStrict": true, 24 | "strictBindCallApply": true, 25 | "strictFunctionTypes": true, 26 | "strictNullChecks": true, 27 | "strictPropertyInitialization": true, 28 | "noFallthroughCasesInSwitch": true, 29 | "noImplicitAny": true, 30 | "noUncheckedIndexedAccess": true, 31 | "noImplicitOverride": true, 32 | "noUnusedLocals": true, 33 | "noUnusedParameters": true, 34 | "noImplicitReturns": true, 35 | "noImplicitThis": true, 36 | "noPropertyAccessFromIndexSignature": true, 37 | "allowUnreachableCode": false, 38 | "allowUnusedLabels": false, 39 | "skipLibCheck": true, 40 | 41 | // Output 42 | "declaration": false, 43 | "declarationMap": false, 44 | "noEmit": true, 45 | "noEmitOnError": true, 46 | "sourceMap": true 47 | }, 48 | "include": ["src/**/*", "eslint.config.ts", "commitlint.config.ts"], 49 | "exclude": ["node_modules", "dist"] 50 | } 51 | -------------------------------------------------------------------------------- /documentation/Domain Model.md: -------------------------------------------------------------------------------- 1 | # Domain Model 2 | 3 | ## Enums 4 | 5 | ### VisualizationType 6 | 7 | Rendering type for data: `Heatmap`, `LineChart`, `BarChart`, `AreaChart`, `PieChart`, `DoughnutChart`, `RadarChart`, `PolarAreaChart`, `ScatterChart`, `BubbleChart`, `TagCloud`, `Timeline` 8 | 9 | ### TimeGranularity 10 | 11 | Time grouping: `Daily`, `Weekly`, `Monthly`, `Quarterly`, `Yearly` 12 | 13 | ## Core Types 14 | 15 | ### ScaleConfig 16 | 17 | Min/max bounds for numeric visualizations. `null` = auto-detect from data. 18 | 19 | ### ColumnVisualizationConfig 20 | 21 | Per-property visualization settings stored in view config: 22 | 23 | - `propertyId`: Bases property ID 24 | - `visualizationType`: Selected visualization 25 | - `displayName`: Cached display name 26 | - `scale`: Optional ScaleConfig 27 | 28 | ### PropertyVisualizationPreset 29 | 30 | Global preset (plugin settings) auto-applied by property name pattern: 31 | 32 | - `propertyNamePattern`: Case-insensitive match 33 | - `visualizationType`: Visualization to use 34 | - `scale`: Optional scale 35 | 36 | ## Data Structures 37 | 38 | ### VisualizationDataPoint 39 | 40 | Single data point for visualization: 41 | 42 | - `entry`: Source BasesEntry 43 | - `dateAnchor`: Resolved date (or null) 44 | - `value`: String representation 45 | - `normalizedValue`: Numeric extraction (or null) 46 | 47 | ### HeatmapData / ChartData / TagCloudData / TimelineData 48 | 49 | Aggregated data ready for rendering. Contains cells/points grouped by time period with min/max ranges. 50 | 51 | ## Configuration Hierarchy 52 | 53 | 1. **Per-view column config** (stored in view config) - highest priority 54 | 2. **Global presets** (plugin settings) - matched by property name 55 | 3. **Unconfigured** - shows config card for user selection 56 | -------------------------------------------------------------------------------- /src/app/components/ui/empty-state.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create empty state element 3 | */ 4 | export function createEmptyState( 5 | container: HTMLElement, 6 | message: string, 7 | icon?: string 8 | ): HTMLElement { 9 | const el = container.createDiv({ cls: 'lt-empty' }) 10 | 11 | if (icon) { 12 | el.createDiv({ cls: 'lt-empty-icon', text: icon }) 13 | } 14 | 15 | el.createDiv({ cls: 'lt-empty-text', text: message }) 16 | 17 | return el 18 | } 19 | 20 | /** 21 | * Create loading state element 22 | */ 23 | export function createLoadingState(container: HTMLElement): HTMLElement { 24 | const el = container.createDiv({ cls: 'lt-loading' }) 25 | el.createDiv({ cls: 'lt-loading-spinner' }) 26 | return el 27 | } 28 | 29 | /** 30 | * Create error state element 31 | */ 32 | export function createErrorState( 33 | container: HTMLElement, 34 | message: string, 35 | details?: string 36 | ): HTMLElement { 37 | const el = container.createDiv({ cls: 'lt-error' }) 38 | 39 | el.createDiv({ cls: 'lt-error-icon', text: '⚠️' }) 40 | el.createDiv({ cls: 'lt-error-message', text: message }) 41 | 42 | if (details) { 43 | el.createDiv({ cls: 'lt-error-details', text: details }) 44 | } 45 | 46 | return el 47 | } 48 | 49 | /** 50 | * Messages for different empty state scenarios 51 | */ 52 | export const EMPTY_STATE_MESSAGES = { 53 | noData: 'No data available', 54 | noDateAnchor: 'No date information found for entries', 55 | noBooleanData: 'No boolean values found for heatmap', 56 | noNumericData: 'No numeric values found for chart', 57 | noTagData: 'No tags or lists found', 58 | noProperties: 'No properties configured for visualization', 59 | basesDisabled: 'Bases feature is not enabled in this vault' 60 | } as const 61 | -------------------------------------------------------------------------------- /src/app/types/plugin/plugin-settings.intf.ts: -------------------------------------------------------------------------------- 1 | import type { VisualizationType } from '../visualization/visualization-type.intf' 2 | import type { ScaleConfig } from '../column/column-config.types' 3 | import type { PropertyDefinition } from '../property/property-definition.types' 4 | 5 | /** 6 | * Global preset for a property name pattern 7 | * Applied automatically when a property matches the pattern 8 | */ 9 | export interface PropertyVisualizationPreset { 10 | /** Unique ID for this preset */ 11 | id: string 12 | /** Property name pattern (exact match, case-insensitive) */ 13 | propertyNamePattern: string 14 | /** Visualization type to use */ 15 | visualizationType: VisualizationType 16 | /** Optional scale configuration */ 17 | scale?: ScaleConfig 18 | } 19 | 20 | export interface PluginSettings { 21 | /** 22 | * Global visualization presets by property name 23 | * Applied automatically when a property name matches 24 | */ 25 | visualizationPresets: PropertyVisualizationPreset[] 26 | 27 | /** 28 | * Animation duration in milliseconds 29 | * Controls how long visualization animations take to complete 30 | */ 31 | animationDuration: number 32 | 33 | /** 34 | * Property definitions for capture/editing 35 | * Defines trackable properties with types, defaults, and constraints 36 | */ 37 | propertyDefinitions: PropertyDefinition[] 38 | 39 | /** 40 | * Show confetti animation when completing property capture 41 | * Adds a fun celebration when all properties are saved 42 | */ 43 | showConfettiOnCapture: boolean 44 | } 45 | 46 | export const DEFAULT_SETTINGS: PluginSettings = { 47 | visualizationPresets: [], 48 | animationDuration: 3000, 49 | propertyDefinitions: [], 50 | showConfettiOnCapture: true 51 | } 52 | -------------------------------------------------------------------------------- /src/app/types/column/column-config.types.ts: -------------------------------------------------------------------------------- 1 | import type { BasesPropertyId } from 'obsidian' 2 | import { VisualizationType } from '../visualization/visualization-type.intf' 3 | 4 | /** 5 | * Scale configuration for numeric visualizations 6 | */ 7 | export interface ScaleConfig { 8 | /** Minimum value for the scale (null = auto-detect from data) */ 9 | min: number | null 10 | /** Maximum value for the scale (null = auto-detect from data) */ 11 | max: number | null 12 | } 13 | 14 | /** 15 | * Configuration for a single column's visualization 16 | */ 17 | export interface ColumnVisualizationConfig { 18 | /** The property ID this config applies to */ 19 | propertyId: BasesPropertyId 20 | /** User-selected visualization type */ 21 | visualizationType: VisualizationType 22 | /** Display name (cached from when configured) */ 23 | displayName: string 24 | /** Timestamp when configured */ 25 | configuredAt: number 26 | /** Scale configuration for numeric visualizations (Heatmap, BarChart, LineChart) */ 27 | scale?: ScaleConfig 28 | } 29 | 30 | /** 31 | * Visualization types that support scale configuration 32 | */ 33 | export const SCALE_SUPPORTED_TYPES: VisualizationType[] = [ 34 | VisualizationType.Heatmap, 35 | VisualizationType.BarChart, 36 | VisualizationType.LineChart, 37 | VisualizationType.AreaChart, 38 | VisualizationType.RadarChart, 39 | VisualizationType.ScatterChart, 40 | VisualizationType.BubbleChart 41 | ] 42 | 43 | /** 44 | * Check if a visualization type supports scale configuration 45 | */ 46 | export function supportsScale(vizType: VisualizationType): boolean { 47 | return SCALE_SUPPORTED_TYPES.includes(vizType) 48 | } 49 | 50 | /** 51 | * Map of property IDs to their visualization configs 52 | * Stored in view config to persist across sessions 53 | */ 54 | export type ColumnConfigMap = Record 55 | -------------------------------------------------------------------------------- /src/utils/dom.utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS class names used for DOM queries and element identification. 3 | * These must match the class names in styles.src.css. 4 | */ 5 | export const CSS_CLASS = { 6 | // Layout 7 | CARD: 'lt-card', 8 | HIDDEN: 'lt-hidden', 9 | 10 | // Visualizations 11 | HEATMAP_CELL: 'lt-heatmap-cell', 12 | 13 | // Settings 14 | PROPERTY_DETAILS: 'lt-property-details' 15 | } as const 16 | 17 | /** 18 | * CSS selectors for DOM queries. 19 | */ 20 | export const CSS_SELECTOR = { 21 | CARD: `.${CSS_CLASS.CARD}`, 22 | HEATMAP_CELL: `.${CSS_CLASS.HEATMAP_CELL}`, 23 | PROPERTY_DETAILS: `.${CSS_CLASS.PROPERTY_DETAILS}` 24 | } as const 25 | 26 | /** 27 | * Data attribute names (without 'data-' prefix) for use with dataset API. 28 | */ 29 | export const DATA_ATTR = { 30 | PROPERTY_ID: 'propertyId', 31 | FILE_PATH: 'filePath', 32 | ROW_INDEX: 'rowIndex' 33 | } as const 34 | 35 | /** 36 | * Data attribute names (with 'data-' prefix) for use with getAttribute/setAttribute. 37 | */ 38 | export const DATA_ATTR_FULL = { 39 | PROPERTY_ID: 'data-property-id', 40 | FILE_PATH: 'data-file-path', 41 | ROW_INDEX: 'data-row-index' 42 | } as const 43 | 44 | /** 45 | * Set CSS properties on an element using setProperty for dynamic values. 46 | * This is preferred over direct element.style.* assignment for maintainability. 47 | * Use CSS classes where possible; use this function only for truly dynamic values 48 | * like computed dimensions, gaps, or other config-driven styles. 49 | */ 50 | export function setCssProps(el: HTMLElement, props: Record): void { 51 | for (const [key, value] of Object.entries(props)) { 52 | // Convert camelCase to kebab-case for CSS property names 53 | const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase() 54 | el.style.setProperty(cssKey, typeof value === 'number' ? `${value}px` : value) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | // Color utilities 2 | export { 3 | HEATMAP_PRESETS, 4 | getHeatmapColor, 5 | getColorLevelForValue, 6 | DEFAULT_CHART_COLORS, 7 | CHART_COLORS_HEX, 8 | BOOLEAN_COLORS, 9 | getChartColor, 10 | getColorWithAlpha, 11 | generateGradient, 12 | isDarkTheme, 13 | getThemeAwareHeatmapColors, 14 | applyHeatmapColorScheme, 15 | getBooleanColor 16 | } from './color.utils' 17 | 18 | // Date utilities 19 | export { 20 | isValidDate, 21 | parseDateFromFilename, 22 | getDateFromISOWeek, 23 | getISOWeekNumber, 24 | getQuarter, 25 | addDays, 26 | addWeeks, 27 | addMonths, 28 | isSameDay, 29 | isSameWeek, 30 | isSameMonth, 31 | isSameQuarter, 32 | isSameYear, 33 | startOfDay, 34 | startOfWeek, 35 | startOfMonth, 36 | startOfQuarter, 37 | startOfYear, 38 | getWeeksBetween, 39 | formatDateISO, 40 | formatDateByGranularity, 41 | getMonthName, 42 | formatFileTitleWithWeekday 43 | } from './date.utils' 44 | 45 | // DOM utilities 46 | export { CSS_CLASS, CSS_SELECTOR, DATA_ATTR, DATA_ATTR_FULL, setCssProps } from './dom.utils' 47 | 48 | // Logging utilities 49 | export { log } from './log.utils' 50 | 51 | // Validation utilities 52 | export { 53 | isEmpty, 54 | validateText, 55 | validateNumber, 56 | validateBoolean, 57 | validateDate, 58 | validateDatetime, 59 | validateList, 60 | validateTags, 61 | parseListValue, 62 | parseTagsValue, 63 | // Input blocking utilities 64 | isValidNumberKeystroke, 65 | clampToRange, 66 | sanitizeNumberPaste, 67 | isInAllowedValues, 68 | isTagAllowed, 69 | setupNumberInputBlocking 70 | } from './validation.utils' 71 | 72 | // Value extraction utilities 73 | export { 74 | extractNumber, 75 | extractBoolean, 76 | extractDate, 77 | extractDisplayLabel, 78 | extractList, 79 | isDateLike 80 | } from './value.utils' 81 | -------------------------------------------------------------------------------- /documentation/Business Rules.md: -------------------------------------------------------------------------------- 1 | # Business Rules 2 | 3 | This document defines the core business rules. These rules MUST be respected in all implementations unless explicitly approved otherwise. 4 | 5 | --- 6 | 7 | ## Documentation Guidelines 8 | 9 | When a new business rule is mentioned: 10 | 11 | 1. Add it to this document immediately 12 | 2. Use a concise format (single line or brief paragraph) 13 | 3. Maintain precision - do not lose important details for brevity 14 | 4. Include rationale where it adds clarity 15 | 16 | --- 17 | 18 | ## Date Anchor Resolution 19 | 20 | Priority order for resolving an entry's date: 21 | 22 | 1. Filename pattern (YYYY-MM-DD, YYYY-Www, YYYY-MM, YYYY-Qq) 23 | 2. Configured date anchor property 24 | 3. File metadata (ctime, mtime) 25 | 26 | ## Configuration Priority 27 | 28 | 1. Per-view column config overrides global presets 29 | 2. Global presets match by case-insensitive property name 30 | 3. Unconfigured properties show selection card 31 | 32 | ## Visualization Types 33 | 34 | - Scale-supporting types: Heatmap, BarChart, LineChart, AreaChart, RadarChart, ScatterChart, BubbleChart 35 | - Non-scale types: PieChart, DoughnutChart, PolarAreaChart, TagCloud, Timeline 36 | 37 | ## Maximize State 38 | 39 | - Only configured cards (with `data-property-id`) participate in maximize/minimize 40 | - Unconfigured cards are hidden when another card is maximized, but never receive maximize state 41 | - Escape key minimizes the currently maximized card 42 | 43 | ## Property Types in Visualizations 44 | 45 | All property types are supported for visualization rendering: 46 | 47 | - `note.*` - frontmatter properties from notes (e.g., `note.energy_level`) 48 | - `formula.*` - computed formula columns in Bases (e.g., `formula.weekly_average`) 49 | - `file.*` - file metadata (e.g., `file.ctime`, `file.mtime`, `file.size`) 50 | 51 | ## Animation and State Transitions 52 | 53 | - Ongoing animations must be stopped before maximizing or minimizing a visualization 54 | 55 | ## Release Tags 56 | 57 | - Tags MUST NOT have 'v' prefix per Obsidian plugin spec (e.g., `1.0.0` not `v1.0.0`) 58 | -------------------------------------------------------------------------------- /scripts/build.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'bun:test' 2 | import { 3 | ASSETS_SRC, 4 | BANNER, 5 | DIST, 6 | EXTERNAL_MODULES, 7 | PLUGIN_ID, 8 | SRC, 9 | STYLES_OUT, 10 | STYLES_SRC 11 | } from './build' 12 | 13 | describe('build constants', () => { 14 | test('SRC is set to src', () => { 15 | expect(SRC).toBe('src') 16 | }) 17 | 18 | test('DIST is set to dist', () => { 19 | expect(DIST).toBe('dist') 20 | }) 21 | 22 | test('ASSETS_SRC is set to src/assets', () => { 23 | expect(ASSETS_SRC).toBe('src/assets') 24 | }) 25 | 26 | test('STYLES_SRC is set to src/styles.src.css', () => { 27 | expect(STYLES_SRC).toBe('src/styles.src.css') 28 | }) 29 | 30 | test('STYLES_OUT is set to dist/styles.css', () => { 31 | expect(STYLES_OUT).toBe('dist/styles.css') 32 | }) 33 | 34 | test('PLUGIN_ID is set correctly', () => { 35 | expect(PLUGIN_ID).toBe('life-tracker') 36 | }) 37 | 38 | test('BANNER contains expected text', () => { 39 | expect(BANNER).toContain('GENERATED/BUNDLED FILE BY BUN') 40 | expect(BANNER).toContain('github repository') 41 | }) 42 | }) 43 | 44 | describe('EXTERNAL_MODULES', () => { 45 | test('includes obsidian', () => { 46 | expect(EXTERNAL_MODULES).toContain('obsidian') 47 | }) 48 | 49 | test('includes electron', () => { 50 | expect(EXTERNAL_MODULES).toContain('electron') 51 | }) 52 | 53 | test('includes codemirror modules', () => { 54 | expect(EXTERNAL_MODULES).toContain('@codemirror/autocomplete') 55 | expect(EXTERNAL_MODULES).toContain('@codemirror/state') 56 | expect(EXTERNAL_MODULES).toContain('@codemirror/view') 57 | }) 58 | 59 | test('includes lezer modules', () => { 60 | expect(EXTERNAL_MODULES).toContain('@lezer/common') 61 | expect(EXTERNAL_MODULES).toContain('@lezer/highlight') 62 | expect(EXTERNAL_MODULES).toContain('@lezer/lr') 63 | }) 64 | 65 | test('has expected number of external modules', () => { 66 | expect(EXTERNAL_MODULES.length).toBe(13) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /scripts/generate-changelog.spec.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, test } from 'bun:test' 2 | import { unlinkSync, writeFileSync } from 'node:fs' 3 | 4 | const TEST_CHANGELOG = 'CHANGELOG.test.md' 5 | 6 | describe('getLatestChangelogEntry', () => { 7 | beforeAll(() => { 8 | // Create a test changelog file 9 | const content = `# Changelog 10 | 11 | ## [1.2.0] - 2024-01-15 12 | 13 | ### Added 14 | - New feature A 15 | - New feature B 16 | 17 | ### Fixed 18 | - Bug fix 1 19 | 20 | ## [1.1.0] - 2024-01-01 21 | 22 | ### Added 23 | - Initial feature 24 | 25 | ## [1.0.0] - 2023-12-01 26 | 27 | ### Added 28 | - First release 29 | ` 30 | writeFileSync(TEST_CHANGELOG, content) 31 | }) 32 | 33 | afterAll(() => { 34 | try { 35 | unlinkSync(TEST_CHANGELOG) 36 | } catch { 37 | // Ignore if file doesn't exist 38 | } 39 | }) 40 | 41 | test('extracts latest version section', async () => { 42 | const testFile = Bun.file(TEST_CHANGELOG) 43 | 44 | // Read test content and verify extraction logic 45 | const content = await testFile.text() 46 | const sections = content.split(/^## /m) 47 | 48 | expect(sections.length).toBeGreaterThan(2) 49 | expect(sections[1]).toContain('[1.2.0]') 50 | expect(sections[1]).toContain('New feature A') 51 | }) 52 | 53 | test('returns empty string for non-existent file', async () => { 54 | // This tests the edge case handling 55 | const nonExistentFile = Bun.file('CHANGELOG.nonexistent.md') 56 | const exists = await nonExistentFile.exists() 57 | expect(exists).toBe(false) 58 | }) 59 | }) 60 | 61 | describe('changelog format', () => { 62 | test('conventional changelog format is valid', () => { 63 | // Verify the expected format structure 64 | const sampleEntry = `## [1.0.0] - 2024-01-01 65 | 66 | ### Added 67 | - Feature 1 68 | 69 | ### Fixed 70 | - Bug 1 71 | ` 72 | expect(sampleEntry).toMatch(/^## \[\d+\.\d+\.\d+\]/) 73 | expect(sampleEntry).toContain('### Added') 74 | expect(sampleEntry).toContain('### Fixed') 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /src/app/services/chart-loader.service.ts: -------------------------------------------------------------------------------- 1 | import { log } from '../../utils' 2 | 3 | /** 4 | * Lazy-loaded Chart.js module 5 | */ 6 | let chartJsModule: typeof import('chart.js') | null = null 7 | let isLoading = false 8 | let loadPromise: Promise | null = null 9 | 10 | /** 11 | * Service for managing Chart.js loading and registration. 12 | * Ensures Chart.js is only loaded and registered once globally. 13 | */ 14 | export class ChartLoaderService { 15 | /** 16 | * Get the Chart.js module, loading it if necessary. 17 | * This method is idempotent - multiple calls will return the same instance. 18 | */ 19 | static async getChartJs(): Promise { 20 | // Already loaded 21 | if (chartJsModule) { 22 | return chartJsModule 23 | } 24 | 25 | // Currently loading - wait for existing promise 26 | if (isLoading && loadPromise) { 27 | return loadPromise 28 | } 29 | 30 | // Start loading 31 | isLoading = true 32 | loadPromise = this.loadAndRegister() 33 | 34 | try { 35 | chartJsModule = await loadPromise 36 | return chartJsModule 37 | } finally { 38 | isLoading = false 39 | } 40 | } 41 | 42 | /** 43 | * Load Chart.js and register all components 44 | */ 45 | private static async loadAndRegister(): Promise { 46 | log('Loading Chart.js...', 'debug') 47 | const startTime = performance.now() 48 | 49 | const module = await import('chart.js') 50 | 51 | // Register all components once 52 | module.Chart.register(...module.registerables) 53 | 54 | const loadTime = performance.now() - startTime 55 | log(`Chart.js loaded and registered in ${loadTime.toFixed(1)}ms`, 'debug') 56 | 57 | return module 58 | } 59 | 60 | /** 61 | * Check if Chart.js is already loaded 62 | */ 63 | static isLoaded(): boolean { 64 | return chartJsModule !== null 65 | } 66 | 67 | /** 68 | * Preload Chart.js in the background (non-blocking) 69 | */ 70 | static preload(): void { 71 | if (!chartJsModule && !isLoading) { 72 | void this.getChartJs() 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/dom.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'bun:test' 2 | import { CSS_CLASS, CSS_SELECTOR, DATA_ATTR, DATA_ATTR_FULL } from './dom.utils' 3 | 4 | describe('dom-constants', () => { 5 | describe('CSS_CLASS', () => { 6 | test('contains expected class names', () => { 7 | expect(CSS_CLASS.CARD).toBe('lt-card') 8 | expect(CSS_CLASS.HIDDEN).toBe('lt-hidden') 9 | expect(CSS_CLASS.HEATMAP_CELL).toBe('lt-heatmap-cell') 10 | expect(CSS_CLASS.PROPERTY_DETAILS).toBe('lt-property-details') 11 | }) 12 | 13 | test('class names use lt- prefix', () => { 14 | Object.values(CSS_CLASS).forEach((className) => { 15 | expect(className.startsWith('lt-')).toBe(true) 16 | }) 17 | }) 18 | }) 19 | 20 | describe('CSS_SELECTOR', () => { 21 | test('selectors are derived from CSS_CLASS', () => { 22 | expect(CSS_SELECTOR.CARD).toBe(`.${CSS_CLASS.CARD}`) 23 | expect(CSS_SELECTOR.HEATMAP_CELL).toBe(`.${CSS_CLASS.HEATMAP_CELL}`) 24 | expect(CSS_SELECTOR.PROPERTY_DETAILS).toBe(`.${CSS_CLASS.PROPERTY_DETAILS}`) 25 | }) 26 | 27 | test('selectors start with dot', () => { 28 | Object.values(CSS_SELECTOR).forEach((selector) => { 29 | expect(selector.startsWith('.')).toBe(true) 30 | }) 31 | }) 32 | }) 33 | 34 | describe('DATA_ATTR', () => { 35 | test('contains camelCase attribute names for dataset API', () => { 36 | expect(DATA_ATTR.PROPERTY_ID).toBe('propertyId') 37 | expect(DATA_ATTR.FILE_PATH).toBe('filePath') 38 | expect(DATA_ATTR.ROW_INDEX).toBe('rowIndex') 39 | }) 40 | }) 41 | 42 | describe('DATA_ATTR_FULL', () => { 43 | test('contains data- prefixed attribute names', () => { 44 | expect(DATA_ATTR_FULL.PROPERTY_ID).toBe('data-property-id') 45 | expect(DATA_ATTR_FULL.FILE_PATH).toBe('data-file-path') 46 | expect(DATA_ATTR_FULL.ROW_INDEX).toBe('data-row-index') 47 | }) 48 | 49 | test('all attribute names start with data-', () => { 50 | Object.values(DATA_ATTR_FULL).forEach((attr) => { 51 | expect(attr.startsWith('data-')).toBe(true) 52 | }) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /scripts/create-release-zip.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'bun:test' 2 | 3 | // Note: Integration tests for createReleaseZip should be run from project root 4 | // These are unit tests that don't depend on file system 5 | 6 | describe('zip path format', () => { 7 | test('zip path includes dist directory', () => { 8 | const DIST = 'dist' 9 | const name = 'test-plugin' 10 | const zipPath = `${DIST}/${name}.zip` 11 | 12 | expect(zipPath).toBe('dist/test-plugin.zip') 13 | expect(zipPath).toStartWith('dist/') 14 | expect(zipPath).toEndWith('.zip') 15 | }) 16 | 17 | test('zip path handles package names with hyphens', () => { 18 | const DIST = 'dist' 19 | const name = 'obsidian-life-tracker-base-view-plugin' 20 | const zipPath = `${DIST}/${name}.zip` 21 | 22 | expect(zipPath).toBe('dist/obsidian-life-tracker-base-view-plugin.zip') 23 | }) 24 | }) 25 | 26 | describe('file filtering logic', () => { 27 | test('filters out zip files', () => { 28 | const files = ['main.js', 'manifest.json', 'styles.css', 'old.zip', 'another.zip'] 29 | const filtered = files.filter((f) => !f.endsWith('.zip')) 30 | 31 | expect(filtered).toEqual(['main.js', 'manifest.json', 'styles.css']) 32 | expect(filtered).not.toContain('old.zip') 33 | expect(filtered).not.toContain('another.zip') 34 | }) 35 | 36 | test('keeps all non-zip files', () => { 37 | const files = ['main.js', 'manifest.json', 'styles.css', 'image.png', 'data.json'] 38 | const filtered = files.filter((f) => !f.endsWith('.zip')) 39 | 40 | expect(filtered).toEqual(files) 41 | expect(filtered.length).toBe(5) 42 | }) 43 | 44 | test('handles empty array', () => { 45 | const files: string[] = [] 46 | const filtered = files.filter((f) => !f.endsWith('.zip')) 47 | 48 | expect(filtered).toEqual([]) 49 | expect(filtered.length).toBe(0) 50 | }) 51 | }) 52 | 53 | describe('package name validation', () => { 54 | test('expected package name format', () => { 55 | const expectedName = 'obsidian-life-tracker-base-view-plugin' 56 | 57 | expect(expectedName).toMatch(/^[a-z0-9-]+$/) 58 | expect(expectedName).toContain('obsidian') 59 | expect(expectedName).toContain('plugin') 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /documentation/Configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Plugin Settings (Global) 4 | 5 | Stored in plugin data, applies to all views. 6 | 7 | | Setting | Type | Default | Description | 8 | | ---------------------- | ----------------------------- | ------- | ------------------------------------------------- | 9 | | `visualizationPresets` | PropertyVisualizationPreset[] | `[]` | Auto-apply visualization by property name pattern | 10 | | `animationDuration` | number | `3000` | Chart animation duration (ms) | 11 | 12 | ## View Options (Per-View) 13 | 14 | Configured via Obsidian's view options UI. 15 | 16 | | Option | Type | Default | Description | 17 | | -------------------- | --------------- | ------- | ----------------------------- | 18 | | `granularity` | TimeGranularity | `daily` | Time grouping for aggregation | 19 | | `dateAnchorProperty` | property | (none) | Override date anchor property | 20 | | `embeddedHeight` | number | `400` | Height in embedded mode (px) | 21 | | `cellSize` | number | `12` | Heatmap cell size (px) | 22 | | `showEmptyDates` | boolean | `true` | Show dates with no data | 23 | | `showDayLabels` | boolean | `true` | Show day labels on heatmaps | 24 | | `showMonthLabels` | boolean | `true` | Show month labels on heatmaps | 25 | | `heatmapColorScheme` | string | `green` | Heatmap color preset | 26 | | `gridColumns` | number | `3` | Grid columns (1-6) | 27 | | `showLegend` | boolean | `true` | Show chart legends | 28 | 29 | ## Per-Column Config (Per-View) 30 | 31 | Stored in view config under `columnConfigs` key. 32 | 33 | | Field | Description | 34 | | ------------------- | ---------------------- | 35 | | `propertyId` | Bases property ID | 36 | | `visualizationType` | Selected visualization | 37 | | `displayName` | Cached property name | 38 | | `configuredAt` | Timestamp | 39 | | `scale` | Optional {min, max} | 40 | 41 | ## Scale Presets 42 | 43 | Available via context menu: `0-1`, `0-5`, `1-5`, `0-10`, `1-10`, `0-100`, or auto-detect. 44 | 45 | ## Heatmap Color Schemes 46 | 47 | `green` (default), `blue`, `purple`, `orange`, `red` 48 | -------------------------------------------------------------------------------- /src/app/components/ui/grid-controls.ts: -------------------------------------------------------------------------------- 1 | import { setIcon } from 'obsidian' 2 | import type { GridSettings, GridSettingsChangeCallback } from '../../types' 3 | 4 | /** 5 | * Default grid settings 6 | */ 7 | export const DEFAULT_GRID_SETTINGS: GridSettings = { 8 | columns: 2 9 | } 10 | 11 | /** 12 | * Creates a control bar for adjusting grid layout 13 | */ 14 | export function createGridControls( 15 | container: HTMLElement, 16 | initialSettings: GridSettings, 17 | onChange: GridSettingsChangeCallback 18 | ): HTMLElement { 19 | const settings = { ...initialSettings } 20 | 21 | const controlBar = container.createDiv({ cls: 'lt-control-bar' }) 22 | 23 | // Left side: title/info (optional, currently empty) 24 | controlBar.createDiv({ cls: 'lt-control-bar-left' }) 25 | 26 | // Right side: controls 27 | const controlsRight = controlBar.createDiv({ cls: 'lt-control-bar-right' }) 28 | 29 | // Columns control 30 | createColumnsControl(controlsRight, settings, onChange) 31 | 32 | return controlBar 33 | } 34 | 35 | /** 36 | * Creates column count control with +/- buttons 37 | */ 38 | function createColumnsControl( 39 | container: HTMLElement, 40 | settings: GridSettings, 41 | onChange: GridSettingsChangeCallback 42 | ): void { 43 | const group = container.createDiv({ cls: 'lt-control-group' }) 44 | 45 | // Icon 46 | const iconEl = group.createDiv({ cls: 'lt-control-icon' }) 47 | setIcon(iconEl, 'layout-grid') 48 | 49 | // Minus button 50 | const minusBtn = group.createEl('button', { cls: 'lt-control-btn' }) 51 | setIcon(minusBtn, 'minus') 52 | minusBtn.setAttribute('aria-label', 'Fewer columns') 53 | 54 | // Value display 55 | const valueEl = group.createSpan({ cls: 'lt-control-value', text: String(settings.columns) }) 56 | 57 | // Plus button 58 | const plusBtn = group.createEl('button', { cls: 'lt-control-btn' }) 59 | setIcon(plusBtn, 'plus') 60 | plusBtn.setAttribute('aria-label', 'More columns') 61 | 62 | // Event handlers 63 | minusBtn.addEventListener('click', () => { 64 | if (settings.columns > 1) { 65 | settings.columns-- 66 | valueEl.textContent = String(settings.columns) 67 | onChange(settings) 68 | } 69 | }) 70 | 71 | plusBtn.addEventListener('click', () => { 72 | if (settings.columns < 6) { 73 | settings.columns++ 74 | valueEl.textContent = String(settings.columns) 75 | onChange(settings) 76 | } 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /scripts/version-bump.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Updates manifest.json and versions.json with the target version. 3 | * The target version is read from npm_package_version environment variable. 4 | * Usage: npm_package_version=1.2.3 bun scripts/version-bump.ts 5 | */ 6 | 7 | import { file } from 'bun' 8 | 9 | export interface ManifestJson { 10 | id: string 11 | name: string 12 | version: string 13 | minAppVersion: string 14 | [key: string]: unknown 15 | } 16 | 17 | export interface VersionsJson { 18 | [version: string]: string 19 | } 20 | 21 | export async function readManifest(): Promise { 22 | const manifestFile = file('manifest.json') 23 | return (await manifestFile.json()) as ManifestJson 24 | } 25 | 26 | export async function writeManifest(manifest: ManifestJson): Promise { 27 | const manifestFile = file('manifest.json') 28 | await Bun.write(manifestFile, JSON.stringify(manifest, null, 4) + '\n') 29 | } 30 | 31 | export async function readVersions(): Promise { 32 | const versionsFile = file('versions.json') 33 | return (await versionsFile.json()) as VersionsJson 34 | } 35 | 36 | export async function writeVersions(versions: VersionsJson): Promise { 37 | const versionsFile = file('versions.json') 38 | await Bun.write(versionsFile, JSON.stringify(versions, null, 4) + '\n') 39 | } 40 | 41 | export async function bumpVersion(targetVersion: string): Promise { 42 | // Read and update manifest.json 43 | const manifest = await readManifest() 44 | const { minAppVersion } = manifest 45 | manifest.version = targetVersion 46 | await writeManifest(manifest) 47 | console.log(`Updated manifest.json version to ${targetVersion}`) 48 | 49 | // Update versions.json if this minAppVersion is not already tracked 50 | const versions = await readVersions() 51 | if (!Object.values(versions).includes(minAppVersion)) { 52 | versions[targetVersion] = minAppVersion 53 | await writeVersions(versions) 54 | console.log(`Added ${targetVersion} -> ${minAppVersion} to versions.json`) 55 | } else { 56 | console.log(`versions.json already contains minAppVersion ${minAppVersion}`) 57 | } 58 | } 59 | 60 | // Only run if executed directly 61 | if (import.meta.main) { 62 | const targetVersion = Bun.env['npm_package_version'] 63 | 64 | if (!targetVersion) { 65 | console.error('Error: npm_package_version environment variable is not set.') 66 | console.error('Usage: npm_package_version=1.2.3 bun scripts/version-bump.ts') 67 | process.exit(1) 68 | } 69 | 70 | await bumpVersion(targetVersion) 71 | } 72 | -------------------------------------------------------------------------------- /src/app/commands/capture-command.ts: -------------------------------------------------------------------------------- 1 | import { Notice, type TFile } from 'obsidian' 2 | import type { LifeTrackerPlugin } from '../plugin' 3 | import type { BatchFilterMode } from '../types' 4 | import { PropertyCaptureModal } from '../components/modals/property-capture-modal' 5 | 6 | /** 7 | * Context for the property capture dialog 8 | */ 9 | export interface CaptureContext { 10 | mode: 'single-note' | 'batch' 11 | /** Current file (single-note mode) */ 12 | file?: TFile 13 | /** All files (batch mode) */ 14 | files?: TFile[] 15 | /** Current index in batch (batch mode) */ 16 | currentIndex?: number 17 | /** Filter mode for batch - determines when a file is considered complete */ 18 | filterMode?: BatchFilterMode 19 | } 20 | 21 | /** 22 | * Register the capture properties command 23 | */ 24 | export function registerCaptureCommand(plugin: LifeTrackerPlugin): void { 25 | plugin.addCommand({ 26 | id: 'capture-properties', 27 | name: 'Capture properties', 28 | callback: () => { 29 | // Check if property definitions are configured 30 | if (plugin.settings.propertyDefinitions.length === 0) { 31 | new Notice( 32 | 'No property definitions configured. Add them in Settings → Life Tracker → Property Definitions.' 33 | ) 34 | return 35 | } 36 | 37 | const context = detectContext(plugin) 38 | 39 | if (!context) { 40 | new Notice('Please open a markdown file or a Life Tracker view first') 41 | return 42 | } 43 | 44 | new PropertyCaptureModal(plugin, context).open() 45 | } 46 | }) 47 | } 48 | 49 | /** 50 | * Detect the capture context based on current workspace state 51 | */ 52 | function detectContext(plugin: LifeTrackerPlugin): CaptureContext | null { 53 | const app = plugin.app 54 | 55 | // First, check for active file 56 | const activeFile = app.workspace.getActiveFile() 57 | 58 | if (activeFile && activeFile.extension === 'md') { 59 | return { 60 | mode: 'single-note', 61 | file: activeFile 62 | } 63 | } 64 | 65 | // Check for active file provider (Grid View or Life Tracker View) 66 | const providerFiles = plugin.getActiveProviderFiles() 67 | const filterMode = plugin.getActiveProviderFilterMode() 68 | 69 | if (providerFiles && providerFiles.length > 0) { 70 | return { 71 | mode: 'batch', 72 | files: providerFiles, 73 | currentIndex: 0, 74 | filterMode: filterMode ?? 'never' 75 | } 76 | } 77 | 78 | return null 79 | } 80 | -------------------------------------------------------------------------------- /scripts/version-bump.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'bun:test' 2 | import type { ManifestJson, VersionsJson } from './version-bump' 3 | 4 | describe('ManifestJson interface', () => { 5 | test('valid manifest structure', () => { 6 | const manifest: ManifestJson = { 7 | id: 'test-plugin', 8 | name: 'Test Plugin', 9 | version: '1.0.0', 10 | minAppVersion: '1.4.0' 11 | } 12 | 13 | expect(manifest.id).toBe('test-plugin') 14 | expect(manifest.name).toBe('Test Plugin') 15 | expect(manifest.version).toBe('1.0.0') 16 | expect(manifest.minAppVersion).toBe('1.4.0') 17 | }) 18 | 19 | test('manifest allows additional properties', () => { 20 | const manifest: ManifestJson = { 21 | id: 'test-plugin', 22 | name: 'Test Plugin', 23 | version: '1.0.0', 24 | minAppVersion: '1.4.0', 25 | author: 'Test Author', 26 | description: 'A test plugin' 27 | } 28 | 29 | expect(manifest.author).toBe('Test Author') 30 | expect(manifest.description).toBe('A test plugin') 31 | }) 32 | }) 33 | 34 | describe('VersionsJson interface', () => { 35 | test('valid versions structure', () => { 36 | const versions: VersionsJson = { 37 | '1.0.0': '0.15.0', 38 | '1.1.0': '1.0.0' 39 | } 40 | 41 | expect(versions['1.0.0']).toBe('0.15.0') 42 | expect(versions['1.1.0']).toBe('1.0.0') 43 | }) 44 | 45 | test('versions keys should be semver', () => { 46 | const versions: VersionsJson = { 47 | '1.0.0': '0.15.0' 48 | } 49 | 50 | const key = Object.keys(versions)[0] 51 | expect(key).toMatch(/^\d+\.\d+\.\d+$/) 52 | }) 53 | 54 | test('versions values should be semver', () => { 55 | const versions: VersionsJson = { 56 | '1.0.0': '0.15.0' 57 | } 58 | 59 | const value = Object.values(versions)[0] 60 | expect(value).toMatch(/^\d+\.\d+\.\d+$/) 61 | }) 62 | }) 63 | 64 | describe('version format validation', () => { 65 | test('valid semver formats', () => { 66 | const validVersions = ['0.0.1', '1.0.0', '1.2.3', '10.20.30'] 67 | const semverRegex = /^\d+\.\d+\.\d+$/ 68 | 69 | for (const version of validVersions) { 70 | expect(version).toMatch(semverRegex) 71 | } 72 | }) 73 | 74 | test('invalid semver formats', () => { 75 | const invalidVersions = ['1.0', '1', 'v1.0.0', '1.0.0-beta', '1.0.0.0'] 76 | const semverRegex = /^\d+\.\d+\.\d+$/ 77 | 78 | for (const version of invalidVersions) { 79 | expect(version).not.toMatch(semverRegex) 80 | } 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/app/types/index.ts: -------------------------------------------------------------------------------- 1 | // Chart types 2 | export type { 3 | ChartType, 4 | ChartDatasetConfig, 5 | ChartInstance, 6 | ChartClickElement, 7 | CartesianTooltipContext, 8 | PieTooltipContext, 9 | PointTooltipContext 10 | } from './chart' 11 | 12 | // Visualization types 13 | export { VisualizationType, TimeGranularity } from './visualization' 14 | export { 15 | CONFIG_CARD_VISUALIZATION_OPTIONS, 16 | CONTEXT_MENU_VISUALIZATION_OPTIONS, 17 | SCALE_PRESETS, 18 | SETTINGS_TAB_VISUALIZATION_OPTIONS, 19 | SCALE_PRESETS_RECORD 20 | } from './visualization' 21 | export type { 22 | AnimationState, 23 | VisualizationDataPoint, 24 | HeatmapData, 25 | HeatmapCell, 26 | ChartData, 27 | ChartDataset, 28 | PieChartData, 29 | ScatterPoint, 30 | BubblePoint, 31 | ScatterChartData, 32 | BubbleChartData, 33 | TagCloudData, 34 | TagCloudItem, 35 | TimelineData, 36 | TimelinePoint, 37 | HeatmapColorScheme, 38 | VisualizationConfig, 39 | HeatmapConfig, 40 | ChartJsType, 41 | ChartConfig, 42 | TagCloudConfig, 43 | ContextMenuVisualizationOption, 44 | ConfigCardVisualizationOption, 45 | ScalePreset 46 | } from './visualization' 47 | 48 | // Column types 49 | export { SCALE_SUPPORTED_TYPES, supportsScale } from './column' 50 | export type { 51 | ScaleConfig, 52 | ColumnVisualizationConfig, 53 | ColumnConfigMap, 54 | ColumnConfigResult, 55 | ColumnConfigCallback, 56 | EffectiveConfigResult 57 | } from './column' 58 | 59 | // Editor types 60 | export type { PropertyEditor, PropertyEditorConfig, DirtyChangeCallback } from './editor' 61 | 62 | // Property types 63 | export { 64 | PROPERTY_TYPES, 65 | PROPERTY_TYPE_LABELS, 66 | MAPPING_TYPE_LABELS, 67 | createDefaultPropertyDefinition, 68 | createDefaultMapping 69 | } from './property' 70 | export type { 71 | ObsidianPropertyType, 72 | PropertyType, 73 | MappingType, 74 | NumberRange, 75 | PropertyDefaultValue, 76 | PropertyAllowedValues, 77 | PropertyDefinition, 78 | ValidationResult, 79 | Mapping, 80 | PropertyIssue 81 | } from './property' 82 | export { getPropertyDisplayLabel } from './property' 83 | 84 | // UI types 85 | export type { 86 | GridSettings, 87 | GridSettingsChangeCallback, 88 | CardMenuAction, 89 | CardMenuCallback 90 | } from './ui' 91 | 92 | // Plugin types 93 | export { 94 | DEFAULT_SETTINGS, 95 | DEFAULT_BATCH_FILTER_MODE, 96 | BATCH_FILTER_MODE_OPTIONS, 97 | getBatchFilterModeLabel 98 | } from './plugin' 99 | export type { 100 | PluginSettings, 101 | PropertyVisualizationPreset, 102 | SettingsChangeCallback, 103 | SettingsChangeInfo, 104 | FileProvider, 105 | BatchFilterMode, 106 | CaptureContext 107 | } from './plugin' 108 | 109 | // View types 110 | export type { 111 | ConfigGetter, 112 | MaximizeCallback, 113 | GetDataPointsCallback, 114 | DateAnchorSource, 115 | DateAnchorConfig, 116 | ResolvedDateAnchor, 117 | DatePattern 118 | } from './view' 119 | 120 | // Common types 121 | export type { LogLevel } from './common' 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-life-tracker-plugin", 3 | "version": "1.7.0", 4 | "description": "Capture and visualize the data that matters in your life.", 5 | "author": { 6 | "name": "Sébastien Dubois", 7 | "email": "sebastien@developassion.be", 8 | "url": "https://dsebastien.net" 9 | }, 10 | "keywords": [ 11 | "obsidian", 12 | "obsidian-plugin", 13 | "life-tracking", 14 | "obsidian-bases", 15 | "obsidian-base-view" 16 | ], 17 | "license": "MIT", 18 | "private": true, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/dsebastien/obsidian-life-tracker-base-view.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/dsebastien/obsidian-life-tracker-base-view/issues" 25 | }, 26 | "homepage": "https://github.com/dsebastien/obsidian-life-tracker-base-view", 27 | "engines": { 28 | "node": ">=20.0.0", 29 | "npm": ">=10.1.0" 30 | }, 31 | "main": "main.js", 32 | "packageManager": "bun@1.3.2", 33 | "lint-staged": { 34 | "**/*": "prettier --write --ignore-unknown" 35 | }, 36 | "config": { 37 | "commitizen": { 38 | "path": "node_modules/cz-customizable" 39 | }, 40 | "cz-customizable": { 41 | "config": ".cz-config.cjs" 42 | } 43 | }, 44 | "scripts": { 45 | "dev": "bun scripts/build.ts", 46 | "build": "bun run tsc && bun scripts/build.ts --prod", 47 | "tsc": "tsc --noEmit", 48 | "tsc:watch": "tsc --noEmit --watch --preserveWatchOutput", 49 | "tscw": "bun run tsc:watch", 50 | "lint": "eslint . --max-warnings 0", 51 | "lint:fix": "eslint . --fix", 52 | "format": "prettier --write .", 53 | "format:check": "prettier --check .", 54 | "test": "bun test", 55 | "prepare": "husky", 56 | "commit": "cz", 57 | "cm": "cz", 58 | "commit:lint": "commitlint --edit", 59 | "release:update-version": "bun scripts/update-version.ts", 60 | "release:version-bump": "bun scripts/version-bump.ts", 61 | "release:changelog": "bun scripts/generate-changelog.ts", 62 | "release:zip": "bun scripts/create-release-zip.ts" 63 | }, 64 | "devDependencies": { 65 | "@commitlint/cli": "^20.1.0", 66 | "@commitlint/config-conventional": "^20.1.0", 67 | "@eslint/js": "^9.36.0", 68 | "@tailwindcss/cli": "4.1.17", 69 | "@types/bun": "latest", 70 | "@types/canvas-confetti": "1.9.0", 71 | "commitizen": "^4.3.1", 72 | "conventional-changelog-cli": "5.0.0", 73 | "conventional-changelog-conventionalcommits": "9.1.0", 74 | "cz-customizable": "^7.5.1", 75 | "eslint": "^9.36.0", 76 | "eslint-config-prettier": "^10.1.5", 77 | "globals": "16.5.0", 78 | "husky": "^9.1.7", 79 | "lint-staged": "^16.2.7", 80 | "obsidian": "latest", 81 | "prettier": "^3.6.2", 82 | "tailwindcss": "4.1.17", 83 | "typescript": "^5.9.3", 84 | "typescript-eslint": "8.49.0" 85 | }, 86 | "dependencies": { 87 | "canvas-confetti": "1.9.4", 88 | "chart.js": "4.5.1", 89 | "date-fns": "4.1.0", 90 | "immer": "11.0.1" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to this project! 4 | 5 | ## Getting Started 6 | 7 | ### Prerequisites 8 | 9 | - [Bun](https://bun.sh/) (latest version) 10 | - [Git](https://git-scm.com/) 11 | 12 | ### Fork and Clone 13 | 14 | 1. Fork this repository by clicking the "Fork" button on GitHub 15 | 2. Clone your fork locally: 16 | ```bash 17 | git clone https://github.com/YOUR_USERNAME/obsidian-life-tracker-base-view.git 18 | cd obsidian-life-tracker-base-view 19 | ``` 20 | 3. Add the upstream repository as a remote: 21 | ```bash 22 | git remote add upstream https://github.com/dsebastien/obsidian-life-tracker-base-view.git 23 | ``` 24 | 25 | ### Install Dependencies 26 | 27 | ```bash 28 | bun install 29 | ``` 30 | 31 | ## Development Workflow 32 | 33 | ### Create a Branch 34 | 35 | Create a new branch for your changes: 36 | 37 | ```bash 38 | git checkout -b feature/your-feature-name 39 | ``` 40 | 41 | Use descriptive branch names: 42 | 43 | - `feature/` for new features 44 | - `fix/` for bug fixes 45 | - `docs/` for documentation changes 46 | - `refactor/` for code refactoring 47 | 48 | ### Development 49 | 50 | Start the TypeScript watch process: 51 | 52 | ```bash 53 | bun run tsc:watch 54 | ``` 55 | 56 | Optionally, run tests in watch mode: 57 | 58 | ```bash 59 | bun test --watch 60 | ``` 61 | 62 | ### Code Quality 63 | 64 | Before committing, ensure your code passes all checks: 65 | 66 | ```bash 67 | bun run format # Format code 68 | bun run lint # Check for lint errors 69 | bun run tsc # Type check 70 | bun test # Run tests 71 | ``` 72 | 73 | ### Commit Your Changes 74 | 75 | Write clear, concise commit messages following [Conventional Commits](https://www.conventionalcommits.org/): 76 | 77 | ```bash 78 | git add . 79 | git commit -m "feat: add new feature description" 80 | ``` 81 | 82 | Common prefixes: 83 | 84 | - `feat:` new feature 85 | - `fix:` bug fix 86 | - `docs:` documentation changes 87 | - `refactor:` code refactoring 88 | - `test:` adding or updating tests 89 | - `chore:` maintenance tasks 90 | 91 | ### Keep Your Fork Updated 92 | 93 | Before creating a pull request, sync your fork with upstream: 94 | 95 | ```bash 96 | git fetch upstream 97 | git rebase upstream/main 98 | ``` 99 | 100 | Resolve any conflicts if necessary. 101 | 102 | ### Push Your Changes 103 | 104 | ```bash 105 | git push origin feature/your-feature-name 106 | ``` 107 | 108 | ## Creating a Pull Request 109 | 110 | 1. Go to your fork on GitHub 111 | 2. Click "Compare & pull request" 112 | 3. Ensure the base repository is `dsebastien/obsidian-life-tracker-base-view` and base branch is `main` 113 | 4. Fill in the PR template: 114 | - Provide a clear title 115 | - Describe what changes you made and why 116 | - Reference any related issues (e.g., "Fixes #123") 117 | 5. Submit the pull request 118 | 119 | ## Pull Request Guidelines 120 | 121 | - Keep PRs focused on a single change 122 | - Ensure all CI checks pass 123 | - Update documentation if needed 124 | - Add tests for new functionality 125 | - Be responsive to feedback and review comments 126 | 127 | ## Code Style 128 | 129 | This project uses: 130 | 131 | - **ESLint** for linting 132 | - **Prettier** for formatting 133 | - **TypeScript** with strict mode enabled 134 | 135 | The CI pipeline enforces these standards. Run `bun run format` and `bun run lint` before committing. 136 | 137 | ## Questions? 138 | 139 | If you have questions, feel free to open an issue for discussion. 140 | -------------------------------------------------------------------------------- /src/app/components/editing/date-editor.ts: -------------------------------------------------------------------------------- 1 | import type { ValidationResult, PropertyEditorConfig } from '../../types' 2 | import { validateDate, validateDatetime, isEmpty } from '../../../utils' 3 | import { BasePropertyEditor } from './base-editor' 4 | 5 | /** 6 | * Date/datetime editor - renders native date/datetime-local input 7 | */ 8 | export class DateEditor extends BasePropertyEditor { 9 | private inputEl: HTMLInputElement | null = null 10 | 11 | constructor(config: PropertyEditorConfig) { 12 | super(config) 13 | } 14 | 15 | render(container: HTMLElement): void { 16 | this.containerEl = container 17 | container.empty() 18 | 19 | const isDatetime = this.config.definition.type === 'datetime' 20 | 21 | this.inputEl = container.createEl('input', { 22 | cls: this.config.compact 23 | ? 'lt-editor-input lt-editor-input--compact lt-editor-input--date' 24 | : 'lt-editor-input lt-editor-input--date', 25 | type: isDatetime ? 'datetime-local' : 'date' 26 | }) 27 | 28 | // Set current value 29 | const currentValue = this.formatValue(this.config.value) 30 | if (currentValue) { 31 | this.inputEl.value = currentValue 32 | } 33 | 34 | this.inputEl.addEventListener('change', () => { 35 | this.notifyChange(this.inputEl?.value ?? '') 36 | }) 37 | 38 | this.inputEl.addEventListener('blur', () => { 39 | this.notifyCommit() 40 | }) 41 | 42 | this.inputEl.addEventListener('keydown', (e) => { 43 | if (e.key === 'Enter') { 44 | this.notifyEnterKey() 45 | } 46 | }) 47 | } 48 | 49 | private formatValue(value: unknown): string { 50 | if (value === null || value === undefined || value === '') { 51 | return '' 52 | } 53 | 54 | const strValue = String(value) 55 | const isDatetime = this.config.definition.type === 'datetime' 56 | 57 | // Try to parse and format 58 | try { 59 | const date = new Date(strValue) 60 | if (!isNaN(date.getTime())) { 61 | if (isDatetime) { 62 | // Format for datetime-local: YYYY-MM-DDTHH:mm 63 | return date.toISOString().slice(0, 16) 64 | } else { 65 | // Format for date: YYYY-MM-DD 66 | return date.toISOString().slice(0, 10) 67 | } 68 | } 69 | } catch { 70 | // Return original value if parsing fails 71 | } 72 | 73 | return strValue 74 | } 75 | 76 | getValue(): unknown { 77 | const value = this.inputEl?.value ?? '' 78 | return value || undefined 79 | } 80 | 81 | setValue(value: unknown): void { 82 | const formatted = this.formatValue(value) 83 | if (this.inputEl) { 84 | this.inputEl.value = formatted 85 | } 86 | } 87 | 88 | focus(): void { 89 | if (this.inputEl) { 90 | this.inputEl.focus() 91 | } 92 | } 93 | 94 | validate(): ValidationResult { 95 | const value = this.getValue() 96 | 97 | if (this.config.definition.required && isEmpty(value)) { 98 | return { valid: false, error: 'This field is required' } 99 | } 100 | 101 | if (isEmpty(value)) { 102 | return { valid: true } 103 | } 104 | 105 | const isDatetime = this.config.definition.type === 'datetime' 106 | return isDatetime ? validateDatetime(value) : validateDate(value) 107 | } 108 | 109 | override destroy(): void { 110 | this.inputEl = null 111 | super.destroy() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/utils/log.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, spyOn, beforeEach, afterEach } from 'bun:test' 2 | import type { LogLevel } from '../app/types' 3 | import { log } from './log.utils' 4 | 5 | describe('log', () => { 6 | let consoleDebugSpy: ReturnType 7 | let consoleInfoSpy: ReturnType 8 | let consoleWarnSpy: ReturnType 9 | let consoleErrorSpy: ReturnType 10 | let consoleLogSpy: ReturnType 11 | 12 | beforeEach(() => { 13 | consoleDebugSpy = spyOn(console, 'debug').mockImplementation(() => {}) 14 | consoleInfoSpy = spyOn(console, 'info').mockImplementation(() => {}) 15 | consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {}) 16 | consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}) 17 | consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}) 18 | }) 19 | 20 | afterEach(() => { 21 | consoleDebugSpy.mockRestore() 22 | consoleInfoSpy.mockRestore() 23 | consoleWarnSpy.mockRestore() 24 | consoleErrorSpy.mockRestore() 25 | consoleLogSpy.mockRestore() 26 | }) 27 | 28 | describe('log function', () => { 29 | test('uses console.debug for debug level', () => { 30 | log('test message', 'debug') 31 | expect(consoleDebugSpy).toHaveBeenCalledTimes(1) 32 | expect(consoleDebugSpy.mock.calls[0]![0]).toContain('test message') 33 | }) 34 | 35 | test('uses console.debug for info level (Obsidian disallows console.info)', () => { 36 | log('test message', 'info') 37 | expect(consoleDebugSpy).toHaveBeenCalledTimes(1) 38 | expect(consoleDebugSpy.mock.calls[0]![0]).toContain('test message') 39 | }) 40 | 41 | test('uses console.warn for warn level', () => { 42 | log('test message', 'warn') 43 | expect(consoleWarnSpy).toHaveBeenCalledTimes(1) 44 | expect(consoleWarnSpy.mock.calls[0]![0]).toContain('test message') 45 | }) 46 | 47 | test('uses console.error for error level', () => { 48 | log('test message', 'error') 49 | expect(consoleErrorSpy).toHaveBeenCalledTimes(1) 50 | expect(consoleErrorSpy.mock.calls[0]![0]).toContain('test message') 51 | }) 52 | 53 | test('uses console.debug for undefined level', () => { 54 | log('test message') 55 | expect(consoleDebugSpy).toHaveBeenCalledTimes(1) 56 | expect(consoleDebugSpy.mock.calls[0]![0]).toContain('test message') 57 | }) 58 | 59 | test('prefixes message with plugin name', () => { 60 | log('test message', 'debug') 61 | // The message should contain the plugin name (ends with colon) 62 | expect(consoleDebugSpy.mock.calls[0]![0]).toContain(':') 63 | }) 64 | 65 | test('passes additional data to console', () => { 66 | const extraData = { foo: 'bar' } 67 | log('test message', 'debug', extraData) 68 | expect(consoleDebugSpy.mock.calls[0]![1]).toContain(extraData) 69 | }) 70 | 71 | test('passes multiple data arguments', () => { 72 | log('test message', 'debug', 'data1', 'data2', 'data3') 73 | expect(consoleDebugSpy.mock.calls[0]![1]).toContain('data1') 74 | expect(consoleDebugSpy.mock.calls[0]![1]).toContain('data2') 75 | expect(consoleDebugSpy.mock.calls[0]![1]).toContain('data3') 76 | }) 77 | }) 78 | 79 | describe('LogLevel type', () => { 80 | test('accepts valid log levels', () => { 81 | const levels: LogLevel[] = ['debug', 'info', 'warn', 'error'] 82 | levels.forEach((level) => { 83 | expect(() => log('test', level)).not.toThrow() 84 | }) 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /src/app/components/ui/tooltip.ts: -------------------------------------------------------------------------------- 1 | import { formatDateByGranularity } from '../../../utils' 2 | import { TimeGranularity } from '../../types' 3 | 4 | /** 5 | * Shared tooltip component for visualizations 6 | */ 7 | export class Tooltip { 8 | private el: HTMLElement 9 | private visible = false 10 | 11 | constructor(container: HTMLElement) { 12 | this.el = container.createDiv({ cls: 'lt-tooltip' }) 13 | } 14 | 15 | /** 16 | * Show tooltip at position with content 17 | */ 18 | show(x: number, y: number, title: string, value?: string, subtitle?: string): void { 19 | this.el.empty() 20 | 21 | if (title) { 22 | this.el.createDiv({ cls: 'lt-tooltip-title', text: title }) 23 | } 24 | 25 | if (value) { 26 | this.el.createDiv({ cls: 'lt-tooltip-value', text: value }) 27 | } 28 | 29 | if (subtitle) { 30 | this.el.createDiv({ cls: 'lt-tooltip-subtitle', text: subtitle }) 31 | } 32 | 33 | // Position tooltip 34 | this.el.style.left = `${x}px` 35 | this.el.style.top = `${y}px` 36 | 37 | // Show 38 | this.el.classList.add('lt-tooltip--visible') 39 | this.visible = true 40 | 41 | // Adjust position if tooltip goes off screen 42 | requestAnimationFrame(() => { 43 | this.adjustPosition() 44 | }) 45 | } 46 | 47 | /** 48 | * Hide tooltip 49 | */ 50 | hide(): void { 51 | this.el.classList.remove('lt-tooltip--visible') 52 | this.visible = false 53 | } 54 | 55 | /** 56 | * Check if tooltip is visible 57 | */ 58 | isVisible(): boolean { 59 | return this.visible 60 | } 61 | 62 | /** 63 | * Destroy tooltip element 64 | */ 65 | destroy(): void { 66 | this.el.remove() 67 | } 68 | 69 | /** 70 | * Adjust tooltip position to stay within viewport 71 | */ 72 | private adjustPosition(): void { 73 | const rect = this.el.getBoundingClientRect() 74 | const viewportWidth = window.innerWidth 75 | const viewportHeight = window.innerHeight 76 | 77 | // Check right edge 78 | if (rect.right > viewportWidth) { 79 | const newLeft = Math.max(0, viewportWidth - rect.width - 10) 80 | this.el.style.left = `${newLeft}px` 81 | } 82 | 83 | // Check bottom edge 84 | if (rect.bottom > viewportHeight) { 85 | const newTop = Math.max(0, viewportHeight - rect.height - 10) 86 | this.el.style.top = `${newTop}px` 87 | } 88 | } 89 | } 90 | 91 | /** 92 | * Create tooltip content for heatmap cell 93 | */ 94 | export function formatHeatmapTooltip( 95 | date: Date, 96 | value: number | null, 97 | count: number, 98 | displayName: string, 99 | granularity: TimeGranularity = TimeGranularity.Daily 100 | ): { title: string; value: string; subtitle: string } { 101 | const dateStr = formatDateByGranularity(date, granularity) 102 | 103 | const valueStr = value !== null ? value.toFixed(2) : 'No data' 104 | 105 | const subtitle = count > 0 ? `${count} ${count === 1 ? 'entry' : 'entries'}` : '' 106 | 107 | return { 108 | title: dateStr, 109 | value: `${displayName}: ${valueStr}`, 110 | subtitle 111 | } 112 | } 113 | 114 | /** 115 | * Create tooltip content for chart point 116 | */ 117 | export function formatChartTooltip( 118 | label: string, 119 | value: number, 120 | datasetLabel: string 121 | ): { title: string; value: string } { 122 | return { 123 | title: label, 124 | value: `${datasetLabel}: ${value.toFixed(2)}` 125 | } 126 | } 127 | 128 | /** 129 | * Create tooltip content for tag cloud item 130 | */ 131 | export function formatTagTooltip(tag: string, frequency: number): { title: string; value: string } { 132 | return { 133 | title: tag, 134 | value: `${frequency} ${frequency === 1 ? 'occurrence' : 'occurrences'}` 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/app/services/render-cache.service.ts: -------------------------------------------------------------------------------- 1 | import type { BasesEntry, BasesPropertyId } from 'obsidian' 2 | import type { ResolvedDateAnchor, VisualizationDataPoint } from '../types' 3 | 4 | /** 5 | * Service for caching render-related data within a single render cycle. 6 | * Caches are automatically invalidated when a new render cycle starts. 7 | * 8 | * This improves performance by avoiding: 9 | * - Duplicate date anchor resolution 10 | * - Duplicate data point creation 11 | */ 12 | export class RenderCacheService { 13 | private dateAnchorsCache: Map | null = null 14 | private dataPointsCache: Map = new Map() 15 | private cachedEntries: WeakRef[] = [] 16 | 17 | /** 18 | * Start a new render cycle. Invalidates caches if data changed. 19 | * Must be called at the beginning of each onDataUpdated(). 20 | * 21 | * The dateAnchors cache uses BasesEntry objects as Map keys. 22 | * When Obsidian updates data, it may provide new BasesEntry objects 23 | * even for the same files. We must detect this to avoid stale lookups. 24 | */ 25 | startRenderCycle(entries: BasesEntry[]): void { 26 | // Check if the entry objects themselves changed (not just the data) 27 | // This is critical because dateAnchors Map uses BasesEntry as keys 28 | const entriesChanged = this.haveEntriesChanged(entries) 29 | 30 | if (entriesChanged) { 31 | // Entry objects changed - must invalidate dateAnchors cache 32 | // (Map lookup would fail with new entry objects) 33 | this.dateAnchorsCache = null 34 | this.dataPointsCache.clear() 35 | this.updateCachedEntries(entries) 36 | } 37 | } 38 | 39 | /** 40 | * Check if entry objects have changed (new object references) 41 | */ 42 | private haveEntriesChanged(entries: BasesEntry[]): boolean { 43 | // Different count = definitely changed 44 | if (entries.length !== this.cachedEntries.length) { 45 | return true 46 | } 47 | 48 | // Check if all entries are the same object references 49 | for (let i = 0; i < entries.length; i++) { 50 | const cachedRef = this.cachedEntries[i] 51 | const cachedEntry = cachedRef?.deref() 52 | const currentEntry = entries[i] 53 | 54 | // If cached entry was garbage collected or doesn't match, entries changed 55 | if (!cachedEntry || cachedEntry !== currentEntry) { 56 | return true 57 | } 58 | } 59 | 60 | return false 61 | } 62 | 63 | /** 64 | * Update cached entry references 65 | */ 66 | private updateCachedEntries(entries: BasesEntry[]): void { 67 | this.cachedEntries = entries.map((entry) => new WeakRef(entry)) 68 | } 69 | 70 | /** 71 | * Get cached date anchors or null if not cached 72 | */ 73 | getDateAnchors(): Map | null { 74 | return this.dateAnchorsCache 75 | } 76 | 77 | /** 78 | * Cache date anchors for this render cycle 79 | */ 80 | setDateAnchors(anchors: Map): void { 81 | this.dateAnchorsCache = anchors 82 | } 83 | 84 | /** 85 | * Get cached data points for a property or null if not cached 86 | */ 87 | getDataPoints(propertyId: BasesPropertyId): VisualizationDataPoint[] | null { 88 | return this.dataPointsCache.get(propertyId) ?? null 89 | } 90 | 91 | /** 92 | * Cache data points for a property 93 | */ 94 | setDataPoints(propertyId: BasesPropertyId, dataPoints: VisualizationDataPoint[]): void { 95 | this.dataPointsCache.set(propertyId, dataPoints) 96 | } 97 | 98 | /** 99 | * Clear all caches (useful for forced refresh) 100 | */ 101 | clearAll(): void { 102 | this.dateAnchorsCache = null 103 | this.dataPointsCache.clear() 104 | this.cachedEntries = [] 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/app/services/date-grouping.utils.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import { TimeGranularity, type HeatmapCell } from '../types' 3 | import { 4 | addDays, 5 | addMonths, 6 | addWeeks, 7 | formatDateISO, 8 | getQuarter, 9 | isSameDay, 10 | isSameMonth, 11 | isSameQuarter, 12 | isSameWeek, 13 | isSameYear, 14 | startOfDay, 15 | startOfMonth, 16 | startOfQuarter, 17 | startOfWeek, 18 | startOfYear 19 | } from '../../utils' 20 | 21 | /** 22 | * Get time key for grouping dates 23 | */ 24 | export function getTimeKey(date: Date, granularity: TimeGranularity): string { 25 | switch (granularity) { 26 | case TimeGranularity.Daily: 27 | return formatDateISO(date) 28 | 29 | case TimeGranularity.Weekly: 30 | return formatDateISO(startOfWeek(date)) 31 | 32 | case TimeGranularity.Monthly: 33 | return format(date, 'yyyy-MM') 34 | 35 | case TimeGranularity.Quarterly: 36 | return `${format(date, 'yyyy')}-Q${getQuarter(date)}` 37 | 38 | case TimeGranularity.Yearly: 39 | return format(date, 'yyyy') 40 | 41 | default: 42 | return formatDateISO(date) 43 | } 44 | } 45 | 46 | /** 47 | * Normalize date to start of time unit 48 | */ 49 | export function normalizeDate(date: Date, granularity: TimeGranularity): Date { 50 | switch (granularity) { 51 | case TimeGranularity.Daily: 52 | return startOfDay(date) 53 | 54 | case TimeGranularity.Weekly: 55 | return startOfWeek(date) 56 | 57 | case TimeGranularity.Monthly: 58 | return startOfMonth(date) 59 | 60 | case TimeGranularity.Quarterly: 61 | return startOfQuarter(date) 62 | 63 | case TimeGranularity.Yearly: 64 | return startOfYear(date) 65 | 66 | default: 67 | return startOfDay(date) 68 | } 69 | } 70 | 71 | /** 72 | * Increment date by granularity 73 | */ 74 | export function incrementDate(date: Date, granularity: TimeGranularity): Date { 75 | switch (granularity) { 76 | case TimeGranularity.Daily: 77 | return addDays(date, 1) 78 | 79 | case TimeGranularity.Weekly: 80 | return addWeeks(date, 1) 81 | 82 | case TimeGranularity.Monthly: 83 | return addMonths(date, 1) 84 | 85 | case TimeGranularity.Quarterly: 86 | return addMonths(date, 3) 87 | 88 | case TimeGranularity.Yearly: 89 | return addMonths(date, 12) 90 | 91 | default: 92 | return addDays(date, 1) 93 | } 94 | } 95 | 96 | /** 97 | * Check if two dates match based on granularity 98 | */ 99 | export function matchesByGranularity( 100 | date1: Date, 101 | date2: Date, 102 | granularity: TimeGranularity 103 | ): boolean { 104 | switch (granularity) { 105 | case TimeGranularity.Daily: 106 | return isSameDay(date1, date2) 107 | 108 | case TimeGranularity.Weekly: 109 | return isSameWeek(date1, date2) 110 | 111 | case TimeGranularity.Monthly: 112 | return isSameMonth(date1, date2) 113 | 114 | case TimeGranularity.Quarterly: 115 | return isSameQuarter(date1, date2) 116 | 117 | case TimeGranularity.Yearly: 118 | return isSameYear(date1, date2) 119 | 120 | default: 121 | return isSameDay(date1, date2) 122 | } 123 | } 124 | 125 | /** 126 | * Generate empty cells for date range 127 | */ 128 | export function generateEmptyCells( 129 | minDate: Date, 130 | maxDate: Date, 131 | granularity: TimeGranularity, 132 | existingCells: Map 133 | ): HeatmapCell[] { 134 | const cells: HeatmapCell[] = [] 135 | let current = normalizeDate(minDate, granularity) 136 | const end = normalizeDate(maxDate, granularity) 137 | 138 | while (current <= end) { 139 | const key = getTimeKey(current, granularity) 140 | 141 | if (!existingCells.has(key)) { 142 | cells.push({ 143 | date: new Date(current), 144 | value: null, 145 | count: 0, 146 | filePaths: [] 147 | }) 148 | } 149 | 150 | current = incrementDate(current, granularity) 151 | } 152 | 153 | return cells 154 | } 155 | -------------------------------------------------------------------------------- /src/app/components/editing/boolean-editor.ts: -------------------------------------------------------------------------------- 1 | import type { ValidationResult, PropertyEditorConfig } from '../../types' 2 | import { validateBoolean } from '../../../utils' 3 | import { BasePropertyEditor } from './base-editor' 4 | 5 | /** 6 | * Boolean editor - renders as a toggle/checkbox 7 | */ 8 | export class BooleanEditor extends BasePropertyEditor { 9 | private toggleEl: HTMLElement | null = null 10 | private checkboxEl: HTMLInputElement | null = null 11 | private currentValue = false 12 | 13 | constructor(config: PropertyEditorConfig) { 14 | super(config) 15 | this.currentValue = this.parseBoolean(config.value) 16 | } 17 | 18 | render(container: HTMLElement): void { 19 | this.containerEl = container 20 | container.empty() 21 | 22 | if (this.config.compact) { 23 | this.renderCheckbox(container) 24 | } else { 25 | this.renderToggle(container) 26 | } 27 | } 28 | 29 | private renderToggle(container: HTMLElement): void { 30 | this.toggleEl = container.createDiv({ 31 | cls: 'lt-editor-toggle' 32 | }) 33 | 34 | this.updateToggleState() 35 | 36 | this.toggleEl.addEventListener('click', () => { 37 | this.currentValue = !this.currentValue 38 | this.updateToggleState() 39 | this.notifyChange(this.currentValue) 40 | this.notifyCommit() 41 | }) 42 | 43 | this.toggleEl.addEventListener('keydown', (e) => { 44 | if (e.key === 'Enter') { 45 | e.preventDefault() 46 | this.currentValue = !this.currentValue 47 | this.updateToggleState() 48 | this.notifyChange(this.currentValue) 49 | this.notifyEnterKey() 50 | } else if (e.key === ' ') { 51 | e.preventDefault() 52 | this.currentValue = !this.currentValue 53 | this.updateToggleState() 54 | this.notifyChange(this.currentValue) 55 | this.notifyCommit() 56 | } 57 | }) 58 | 59 | // Make focusable 60 | this.toggleEl.tabIndex = 0 61 | } 62 | 63 | private renderCheckbox(container: HTMLElement): void { 64 | const label = container.createEl('label', { 65 | cls: 'lt-editor-checkbox-wrapper' 66 | }) 67 | 68 | this.checkboxEl = label.createEl('input', { 69 | type: 'checkbox', 70 | cls: 'lt-editor-checkbox' 71 | }) 72 | this.checkboxEl.checked = this.currentValue 73 | 74 | this.checkboxEl.addEventListener('change', () => { 75 | this.currentValue = this.checkboxEl?.checked ?? false 76 | this.notifyChange(this.currentValue) 77 | this.notifyCommit() 78 | }) 79 | } 80 | 81 | private updateToggleState(): void { 82 | if (!this.toggleEl) return 83 | 84 | this.toggleEl.empty() 85 | this.toggleEl.classList.toggle('lt-editor-toggle--on', this.currentValue) 86 | 87 | // Create track 88 | const track = this.toggleEl.createDiv({ cls: 'lt-editor-toggle-track' }) 89 | 90 | // Create thumb 91 | track.createDiv({ cls: 'lt-editor-toggle-thumb' }) 92 | } 93 | 94 | private parseBoolean(value: unknown): boolean { 95 | if (typeof value === 'boolean') return value 96 | if (value === null || value === undefined) return false 97 | 98 | const strVal = String(value).toLowerCase() 99 | return strVal === 'true' || strVal === 'yes' || strVal === '1' 100 | } 101 | 102 | getValue(): unknown { 103 | return this.currentValue 104 | } 105 | 106 | setValue(value: unknown): void { 107 | this.currentValue = this.parseBoolean(value) 108 | this.updateToggleState() 109 | if (this.checkboxEl) { 110 | this.checkboxEl.checked = this.currentValue 111 | } 112 | } 113 | 114 | focus(): void { 115 | if (this.toggleEl) { 116 | this.toggleEl.focus() 117 | } else if (this.checkboxEl) { 118 | this.checkboxEl.focus() 119 | } 120 | } 121 | 122 | validate(): ValidationResult { 123 | return validateBoolean(this.currentValue) 124 | } 125 | 126 | override destroy(): void { 127 | this.toggleEl = null 128 | this.checkboxEl = null 129 | super.destroy() 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, readdirSync, watch } from 'node:fs' 2 | import { join } from 'node:path' 3 | import { parseArgs } from 'node:util' 4 | import { $ } from 'bun' 5 | import { Glob } from 'bun' 6 | 7 | // Exported constants for testing 8 | export const SRC = 'src' 9 | export const DIST = 'dist' 10 | export const ASSETS_SRC = `${SRC}/assets` 11 | export const STYLES_SRC = `${SRC}/styles.src.css` 12 | export const STYLES_OUT = `${DIST}/styles.css` 13 | export const PLUGIN_ID = 'life-tracker' 14 | export const BANNER = `/* 15 | THIS IS A GENERATED/BUNDLED FILE BY BUN. 16 | If you want to view the source, please visit the github repository of this plugin. 17 | */ 18 | ` 19 | 20 | export const EXTERNAL_MODULES = [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/collab', 25 | '@codemirror/commands', 26 | '@codemirror/language', 27 | '@codemirror/lint', 28 | '@codemirror/search', 29 | '@codemirror/state', 30 | '@codemirror/view', 31 | '@lezer/common', 32 | '@lezer/highlight', 33 | '@lezer/lr' 34 | ] 35 | 36 | const { values } = parseArgs({ 37 | args: Bun.argv, 38 | options: { 39 | prod: { 40 | type: 'boolean', 41 | default: false 42 | } 43 | }, 44 | strict: true, 45 | allowPositionals: true 46 | }) 47 | const isProd = values.prod 48 | 49 | export function ensureDistDir(): void { 50 | if (!existsSync(DIST)) { 51 | mkdirSync(DIST, { recursive: true }) 52 | } 53 | } 54 | 55 | async function buildStyles(): Promise { 56 | console.log('Building Tailwind CSS...') 57 | try { 58 | const minifyFlag = isProd ? '--minify' : '' 59 | await $`bunx @tailwindcss/cli -i ${STYLES_SRC} -o ${STYLES_OUT} ${minifyFlag}`.quiet() 60 | console.log('CSS build succeeded.') 61 | } catch (error) { 62 | console.error('CSS build failed:', error) 63 | if (isProd) { 64 | throw error 65 | } 66 | } 67 | } 68 | 69 | async function buildJs(): Promise { 70 | console.log(`Building plugin in ${isProd ? 'production' : 'development'} mode...`) 71 | const { success, logs } = await Bun.build({ 72 | banner: BANNER, 73 | entrypoints: [`${SRC}/main.ts`], 74 | outdir: DIST, 75 | external: EXTERNAL_MODULES, 76 | format: 'cjs', 77 | target: 'node', 78 | minify: isProd, 79 | sourcemap: isProd ? false : 'inline', 80 | throw: isProd 81 | }) 82 | if (success) { 83 | console.log('JS build succeeded.') 84 | } else { 85 | console.error('JS build failed.') 86 | console.error(logs) 87 | } 88 | } 89 | 90 | async function copyAssets(): Promise { 91 | console.log('Copying assets...') 92 | const glob = new Glob('**/*') 93 | 94 | for await (const file of glob.scan({ cwd: ASSETS_SRC, onlyFiles: true })) { 95 | const src = join(ASSETS_SRC, file) 96 | const dest = join(DIST, file) 97 | await Bun.write(dest, Bun.file(src)) 98 | console.log(` ✓ ${file}`) 99 | } 100 | 101 | // Also copy manifest.json and versions.json to dist 102 | await Bun.write(join(DIST, 'manifest.json'), Bun.file('manifest.json')) 103 | console.log(' ✓ manifest.json') 104 | await Bun.write(join(DIST, 'versions.json'), Bun.file('versions.json')) 105 | console.log(' ✓ versions.json') 106 | } 107 | 108 | async function copyToVault(): Promise { 109 | const vaultPath = process.env['OBSIDIAN_VAULT_LOCATION'] 110 | if (!vaultPath) { 111 | console.log( 112 | 'Tip: Set OBSIDIAN_VAULT_LOCATION to auto-copy the plugin to your vault after each build.' 113 | ) 114 | return 115 | } 116 | 117 | const pluginDir = join(vaultPath, '.obsidian', 'plugins', PLUGIN_ID) 118 | 119 | if (!existsSync(pluginDir)) { 120 | console.log(`Creating plugin directory: ${pluginDir}`) 121 | mkdirSync(pluginDir, { recursive: true }) 122 | } 123 | 124 | console.log(`Copying dist to ${pluginDir}...`) 125 | 126 | const distFiles = readdirSync(DIST) 127 | for (const file of distFiles) { 128 | const src = join(DIST, file) 129 | const dest = join(pluginDir, file) 130 | await Bun.write(dest, Bun.file(src)) 131 | console.log(` ✓ ${file}`) 132 | } 133 | 134 | // Create .hotreload file for hot-reload plugin support 135 | await Bun.write(join(pluginDir, '.hotreload'), '') 136 | console.log(' ✓ .hotreload') 137 | } 138 | 139 | async function build(): Promise { 140 | ensureDistDir() 141 | await Promise.all([buildJs(), buildStyles(), copyAssets()]) 142 | 143 | if (!isProd) { 144 | await copyToVault() 145 | } 146 | } 147 | 148 | // Only run if executed directly 149 | if (import.meta.main) { 150 | await build() 151 | 152 | if (!isProd) { 153 | watch(`${SRC}`, { recursive: true }, async (event, path) => { 154 | console.log(`Detected ${event} in ${SRC}/${path}`) 155 | await build() 156 | }) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development Guide 2 | 3 | This guide explains how to build, run, and test the plugin locally. 4 | 5 | ## Prerequisites 6 | 7 | - [Bun](https://bun.sh/) (latest version) 8 | - [Git](https://git-scm.com/) 9 | - An Obsidian vault for testing 10 | 11 | ## Setup 12 | 13 | ### Clone the Repository 14 | 15 | ```bash 16 | git clone https://github.com/dsebastien/obsidian-life-tracker-base-view.git 17 | cd obsidian-life-tracker-base-view 18 | ``` 19 | 20 | ### Install Dependencies 21 | 22 | ```bash 23 | bun install 24 | ``` 25 | 26 | ### Configure Vault Location 27 | 28 | Set the `OBSIDIAN_VAULT_LOCATION` environment variable to your vault path. The build script uses this to copy the plugin files directly to your vault for testing. 29 | 30 | #### Windows (PowerShell) 31 | 32 | ```powershell 33 | # Set temporarily for current session 34 | $env:OBSIDIAN_VAULT_LOCATION="C:\Users\YourName\Documents\ObsidianVault" 35 | 36 | # Set permanently for your user 37 | [System.Environment]::SetEnvironmentVariable('OBSIDIAN_VAULT_LOCATION', 'C:\Users\YourName\Documents\ObsidianVault', 'User') 38 | ``` 39 | 40 | #### Linux 41 | 42 | ```bash 43 | # Set temporarily for current session 44 | export OBSIDIAN_VAULT_LOCATION="/home/yourname/Documents/ObsidianVault" 45 | 46 | # Set permanently (add to ~/.bashrc or ~/.zshrc) 47 | echo 'export OBSIDIAN_VAULT_LOCATION="/home/yourname/Documents/ObsidianVault"' >> ~/.bashrc 48 | source ~/.bashrc 49 | ``` 50 | 51 | #### macOS 52 | 53 | ```bash 54 | # Set temporarily for current session 55 | export OBSIDIAN_VAULT_LOCATION="/Users/yourname/Documents/ObsidianVault" 56 | 57 | # Set permanently (add to ~/.zshrc or ~/.bash_profile) 58 | echo 'export OBSIDIAN_VAULT_LOCATION="/Users/yourname/Documents/ObsidianVault"' >> ~/.zshrc 59 | source ~/.zshrc 60 | ``` 61 | 62 | ## Development Workflow 63 | 64 | ### Start Development Mode 65 | 66 | Run the development build with file watching: 67 | 68 | ```bash 69 | bun run dev 70 | ``` 71 | 72 | This will: 73 | 74 | - Compile TypeScript 75 | - Bundle the plugin 76 | - Build Tailwind CSS 77 | - Copy files to your vault's plugin directory 78 | - Watch for changes and rebuild automatically 79 | 80 | ### Type Checking 81 | 82 | Run TypeScript type checking in watch mode: 83 | 84 | ```bash 85 | bun run tsc:watch 86 | ``` 87 | 88 | Keep this running in a separate terminal to catch type errors as you code. 89 | 90 | ### Run Tests 91 | 92 | ```bash 93 | bun test 94 | ``` 95 | 96 | Run tests in watch mode: 97 | 98 | ```bash 99 | bun test --watch 100 | ``` 101 | 102 | ### Linting and Formatting 103 | 104 | ```bash 105 | bun run lint # Check for lint errors 106 | bun run lint:fix # Auto-fix lint errors 107 | bun run format # Format code with Prettier 108 | bun run format:check # Check formatting without changes 109 | ``` 110 | 111 | ## Building for Production 112 | 113 | ```bash 114 | bun run build 115 | ``` 116 | 117 | This creates optimized production files in the `dist/` directory: 118 | 119 | - `main.js` - Bundled plugin code 120 | - `manifest.json` - Plugin manifest 121 | - `styles.css` - Compiled styles 122 | 123 | ## Testing in Obsidian 124 | 125 | 1. Ensure `OBSIDIAN_VAULT_LOCATION` is set correctly 126 | 2. Run `bun run dev` to build and copy files to your vault 127 | 3. Open Obsidian 128 | 4. Go to **Settings → Community plugins** 129 | 5. Disable Safe Mode if prompted 130 | 6. Find and enable the plugin 131 | 7. After making changes, Obsidian will automatically reload the plugin (or use **Ctrl/Cmd + R** to reload) 132 | 133 | ### Manual Installation 134 | 135 | If you prefer not to use the environment variable, manually copy these files to `/.obsidian/plugins/life-tracker/`: 136 | 137 | - `dist/main.js` 138 | - `dist/manifest.json` 139 | - `dist/styles.css` 140 | 141 | ## Available Scripts 142 | 143 | | Script | Description | 144 | | ---------------------- | --------------------------------- | 145 | | `bun run dev` | Development build with watch mode | 146 | | `bun run build` | Production build | 147 | | `bun run tsc` | Type check | 148 | | `bun run tsc:watch` | Type check in watch mode | 149 | | `bun run lint` | Run ESLint | 150 | | `bun run lint:fix` | Fix ESLint errors | 151 | | `bun run format` | Format with Prettier | 152 | | `bun run format:check` | Check formatting | 153 | | `bun test` | Run tests | 154 | | `bun run commit` | Create a commit with Commitizen | 155 | 156 | ## Troubleshooting 157 | 158 | ### Plugin not appearing in Obsidian 159 | 160 | - Verify `OBSIDIAN_VAULT_LOCATION` points to the correct vault 161 | - Check that files exist in `/.obsidian/plugins/life-tracker/` 162 | - Restart Obsidian 163 | - Ensure Community plugins are enabled 164 | 165 | ### Build errors 166 | 167 | - Run `bun install` to ensure dependencies are up to date 168 | - Check `bun run tsc` for TypeScript errors 169 | - Check `bun run lint` for linting issues 170 | 171 | ### Changes not reflecting 172 | 173 | - Use **Ctrl/Cmd + R** in Obsidian to reload 174 | - Check the developer console (**Ctrl/Cmd + Shift + I**) for errors 175 | -------------------------------------------------------------------------------- /src/app/view/visualization-config.helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TimeGranularity, 3 | VisualizationType, 4 | type ColumnVisualizationConfig, 5 | type ChartConfig, 6 | type HeatmapConfig, 7 | type TagCloudConfig, 8 | type VisualizationConfig, 9 | type ConfigGetter 10 | } from '../types' 11 | import { HEATMAP_PRESETS } from '../../utils' 12 | import { DEFAULT_CELL_SIZE, DEFAULT_EMBEDDED_HEIGHT } from './view-options' 13 | 14 | /** 15 | * Get visualization configuration from view config 16 | */ 17 | export function getVisualizationConfig( 18 | vizType: VisualizationType, 19 | columnConfig: ColumnVisualizationConfig, 20 | getConfig: ConfigGetter 21 | ): VisualizationConfig { 22 | const granularity = (getConfig('granularity') as TimeGranularity) ?? TimeGranularity.Daily 23 | const showEmptyValues = (getConfig('showEmptyValues') as boolean) ?? true 24 | const embeddedHeight = (getConfig('embeddedHeight') as number) ?? DEFAULT_EMBEDDED_HEIGHT 25 | 26 | const baseConfig: VisualizationConfig = { 27 | granularity, 28 | showEmptyValues, 29 | embeddedHeight 30 | } 31 | 32 | // Extract scale from column config if present 33 | const scale = columnConfig.scale 34 | 35 | switch (vizType) { 36 | case VisualizationType.Heatmap: { 37 | const colorSchemeName = (getConfig('heatmapColorScheme') as string) ?? 'green' 38 | const colorScheme = HEATMAP_PRESETS[colorSchemeName] ?? HEATMAP_PRESETS['green']! 39 | 40 | return { 41 | ...baseConfig, 42 | colorScheme, 43 | cellSize: (getConfig('heatmapCellSize') as number) ?? DEFAULT_CELL_SIZE, 44 | cellGap: 2, 45 | showMonthLabels: (getConfig('heatmapShowMonthLabels') as boolean) ?? true, 46 | showDayLabels: (getConfig('heatmapShowDayLabels') as boolean) ?? true, 47 | scale 48 | } as HeatmapConfig 49 | } 50 | 51 | case VisualizationType.LineChart: 52 | return { 53 | ...baseConfig, 54 | chartType: mapVisualizationTypeToChartType(vizType), 55 | showLegend: (getConfig('chartShowLegend') as boolean) ?? false, 56 | showGrid: (getConfig('chartShowGrid') as boolean) ?? true, 57 | tension: 0.3, 58 | fill: false, // Line charts don't have fill 59 | scale 60 | } as ChartConfig 61 | 62 | case VisualizationType.AreaChart: 63 | return { 64 | ...baseConfig, 65 | chartType: mapVisualizationTypeToChartType(vizType), 66 | showLegend: (getConfig('chartShowLegend') as boolean) ?? false, 67 | showGrid: (getConfig('chartShowGrid') as boolean) ?? true, 68 | tension: 0.3, 69 | fill: true, // Area charts have fill 70 | scale 71 | } as ChartConfig 72 | 73 | case VisualizationType.BarChart: 74 | case VisualizationType.PieChart: 75 | case VisualizationType.DoughnutChart: 76 | case VisualizationType.RadarChart: 77 | case VisualizationType.PolarAreaChart: 78 | case VisualizationType.ScatterChart: 79 | case VisualizationType.BubbleChart: 80 | return { 81 | ...baseConfig, 82 | chartType: mapVisualizationTypeToChartType(vizType), 83 | showLegend: (getConfig('chartShowLegend') as boolean) ?? false, 84 | showGrid: (getConfig('chartShowGrid') as boolean) ?? true, 85 | tension: 0.3, 86 | scale 87 | } as ChartConfig 88 | 89 | case VisualizationType.TagCloud: 90 | return { 91 | ...baseConfig, 92 | minFontSize: 12, 93 | maxFontSize: 32, 94 | sortBy: 95 | (getConfig('tagCloudSortBy') as 'frequency' | 'alphabetical') ?? 'frequency', 96 | maxTags: (getConfig('tagCloudMaxTags') as number) ?? 50 97 | } as TagCloudConfig 98 | 99 | default: 100 | return baseConfig 101 | } 102 | } 103 | 104 | /** 105 | * Map VisualizationType to Chart.js chart type 106 | */ 107 | export function mapVisualizationTypeToChartType( 108 | vizType: VisualizationType 109 | ): 'line' | 'bar' | 'pie' | 'doughnut' | 'radar' | 'polarArea' | 'scatter' | 'bubble' { 110 | switch (vizType) { 111 | case VisualizationType.LineChart: 112 | case VisualizationType.AreaChart: 113 | return 'line' 114 | case VisualizationType.BarChart: 115 | return 'bar' 116 | case VisualizationType.PieChart: 117 | return 'pie' 118 | case VisualizationType.DoughnutChart: 119 | return 'doughnut' 120 | case VisualizationType.RadarChart: 121 | return 'radar' 122 | case VisualizationType.PolarAreaChart: 123 | return 'polarArea' 124 | case VisualizationType.ScatterChart: 125 | return 'scatter' 126 | case VisualizationType.BubbleChart: 127 | return 'bubble' 128 | default: 129 | return 'line' 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/app/components/editing/text-editor.ts: -------------------------------------------------------------------------------- 1 | import type { ValidationResult, PropertyEditorConfig } from '../../types' 2 | import { validateText, isEmpty } from '../../../utils' 3 | import { BasePropertyEditor } from './base-editor' 4 | 5 | /** 6 | * Text editor - renders as dropdown if allowedValues are defined, 7 | * otherwise as a plain text input 8 | */ 9 | export class TextEditor extends BasePropertyEditor { 10 | private inputEl: HTMLInputElement | null = null 11 | private selectEl: HTMLSelectElement | null = null 12 | 13 | constructor(config: PropertyEditorConfig) { 14 | super(config) 15 | } 16 | 17 | render(container: HTMLElement): void { 18 | this.containerEl = container 19 | container.empty() 20 | 21 | const hasAllowedValues = 22 | this.config.definition.allowedValues && this.config.definition.allowedValues.length > 0 23 | 24 | if (hasAllowedValues) { 25 | this.renderDropdown(container) 26 | } else { 27 | this.renderInput(container) 28 | } 29 | } 30 | 31 | private renderDropdown(container: HTMLElement): void { 32 | this.selectEl = container.createEl('select', { 33 | cls: this.config.compact 34 | ? 'lt-editor-select lt-editor-select--compact' 35 | : 'lt-editor-select' 36 | }) 37 | 38 | // Add empty option 39 | this.selectEl.createEl('option', { 40 | value: '', 41 | text: '— Select —' 42 | }) 43 | 44 | // Add allowed values 45 | for (const value of this.config.definition.allowedValues) { 46 | const strValue = String(value) 47 | this.selectEl.createEl('option', { 48 | value: strValue, 49 | text: strValue 50 | }) 51 | } 52 | 53 | // Set current value (handle null/undefined/object explicitly) 54 | let currentValue = '' 55 | if (this.config.value != null) { 56 | currentValue = 57 | typeof this.config.value === 'object' 58 | ? JSON.stringify(this.config.value) 59 | : String(this.config.value) 60 | } 61 | this.selectEl.value = currentValue 62 | 63 | // Event handlers 64 | this.selectEl.addEventListener('change', () => { 65 | this.notifyChange(this.selectEl?.value ?? '') 66 | }) 67 | 68 | this.selectEl.addEventListener('blur', () => { 69 | this.notifyCommit() 70 | }) 71 | 72 | this.selectEl.addEventListener('keydown', (e) => { 73 | if (e.key === 'Enter') { 74 | this.notifyEnterKey() 75 | } 76 | }) 77 | } 78 | 79 | private renderInput(container: HTMLElement): void { 80 | this.inputEl = container.createEl('input', { 81 | cls: this.config.compact 82 | ? 'lt-editor-input lt-editor-input--compact' 83 | : 'lt-editor-input', 84 | type: 'text', 85 | placeholder: this.config.definition.description ?? this.getDisplayLabel() 86 | }) 87 | 88 | // Set current value (handle null/undefined/object explicitly) 89 | if (this.config.value == null) { 90 | this.inputEl.value = '' 91 | } else if (typeof this.config.value === 'object') { 92 | this.inputEl.value = JSON.stringify(this.config.value) 93 | } else { 94 | this.inputEl.value = String(this.config.value) 95 | } 96 | 97 | // Event handlers 98 | this.inputEl.addEventListener('input', () => { 99 | this.notifyChange(this.inputEl?.value ?? '') 100 | }) 101 | 102 | this.inputEl.addEventListener('blur', () => { 103 | this.notifyCommit() 104 | }) 105 | 106 | this.inputEl.addEventListener('keydown', (e) => { 107 | if (e.key === 'Enter') { 108 | this.notifyEnterKey() 109 | } 110 | }) 111 | } 112 | 113 | getValue(): unknown { 114 | if (this.selectEl) { 115 | return this.selectEl.value 116 | } 117 | return this.inputEl?.value ?? '' 118 | } 119 | 120 | setValue(value: unknown): void { 121 | let strValue = '' 122 | if (value != null) { 123 | strValue = typeof value === 'object' ? JSON.stringify(value) : String(value) 124 | } 125 | if (this.selectEl) { 126 | this.selectEl.value = strValue 127 | } else if (this.inputEl) { 128 | this.inputEl.value = strValue 129 | } 130 | } 131 | 132 | focus(): void { 133 | if (this.selectEl) { 134 | this.selectEl.focus() 135 | } else if (this.inputEl) { 136 | this.inputEl.focus() 137 | } 138 | } 139 | 140 | validate(): ValidationResult { 141 | const value = this.getValue() 142 | 143 | if (this.config.definition.required && isEmpty(value)) { 144 | return { valid: false, error: 'This field is required' } 145 | } 146 | 147 | if (isEmpty(value)) { 148 | return { valid: true } 149 | } 150 | 151 | return validateText(value, this.config.definition) 152 | } 153 | 154 | override destroy(): void { 155 | this.inputEl = null 156 | this.selectEl = null 157 | super.destroy() 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/app/view/view-options.ts: -------------------------------------------------------------------------------- 1 | import type { ViewOption } from 'obsidian' 2 | import { TimeGranularity } from '../types' 3 | 4 | /** 5 | * Default embedded height in pixels 6 | */ 7 | export const DEFAULT_EMBEDDED_HEIGHT = 400 8 | 9 | /** 10 | * Default cell size for heatmap 11 | */ 12 | export const DEFAULT_CELL_SIZE = 12 13 | 14 | /** 15 | * Default number of grid columns 16 | */ 17 | export const DEFAULT_GRID_COLUMNS = 2 18 | 19 | /** 20 | * Get view options for Life Tracker view configuration 21 | */ 22 | export function getLifeTrackerViewOptions(): ViewOption[] { 23 | return [ 24 | // Date anchor configuration 25 | { 26 | type: 'property', 27 | key: 'dateAnchorProperty', 28 | displayName: 'Date anchor property', 29 | placeholder: 'Auto-detect from filename', 30 | filter: (prop: string) => !prop.startsWith('file.') 31 | }, 32 | 33 | // Time granularity 34 | { 35 | type: 'dropdown', 36 | key: 'granularity', 37 | displayName: 'Time granularity', 38 | default: TimeGranularity.Daily, 39 | options: { 40 | [TimeGranularity.Daily]: 'Daily', 41 | [TimeGranularity.Weekly]: 'Weekly', 42 | [TimeGranularity.Monthly]: 'Monthly', 43 | [TimeGranularity.Quarterly]: 'Quarterly', 44 | [TimeGranularity.Yearly]: 'Yearly' 45 | } 46 | }, 47 | 48 | // Layout options 49 | { 50 | type: 'group', 51 | displayName: 'Layout', 52 | items: [ 53 | { 54 | type: 'slider', 55 | key: 'gridColumns', 56 | displayName: 'Number of columns', 57 | min: 1, 58 | max: 6, 59 | step: 1, 60 | default: DEFAULT_GRID_COLUMNS 61 | }, 62 | { 63 | type: 'toggle', 64 | key: 'showEmptyValues', 65 | displayName: 'Show empty values', 66 | default: false 67 | } 68 | ] 69 | }, 70 | 71 | // Heatmap options 72 | { 73 | type: 'group', 74 | displayName: 'Heatmap', 75 | items: [ 76 | { 77 | type: 'slider', 78 | key: 'heatmapCellSize', 79 | displayName: 'Cell size', 80 | min: 8, 81 | max: 24, 82 | step: 2, 83 | default: DEFAULT_CELL_SIZE 84 | }, 85 | { 86 | type: 'toggle', 87 | key: 'heatmapShowMonthLabels', 88 | displayName: 'Show month labels', 89 | default: true 90 | }, 91 | { 92 | type: 'toggle', 93 | key: 'heatmapShowDayLabels', 94 | displayName: 'Show day labels', 95 | default: true 96 | }, 97 | { 98 | type: 'dropdown', 99 | key: 'heatmapColorScheme', 100 | displayName: 'Color scheme', 101 | default: 'green', 102 | options: { 103 | green: 'Green (GitHub)', 104 | blue: 'Blue', 105 | purple: 'Purple', 106 | orange: 'Orange', 107 | red: 'Red' 108 | } 109 | } 110 | ] 111 | }, 112 | 113 | // Chart options 114 | { 115 | type: 'group', 116 | displayName: 'Charts', 117 | items: [ 118 | { 119 | type: 'dropdown', 120 | key: 'chartType', 121 | displayName: 'Default chart type', 122 | default: 'line', 123 | options: { 124 | line: 'Line chart', 125 | bar: 'Bar chart' 126 | } 127 | }, 128 | { 129 | type: 'toggle', 130 | key: 'chartShowLegend', 131 | displayName: 'Show legend', 132 | default: false 133 | }, 134 | { 135 | type: 'toggle', 136 | key: 'chartShowGrid', 137 | displayName: 'Show grid lines', 138 | default: true 139 | } 140 | ] 141 | }, 142 | 143 | // Tag cloud options 144 | { 145 | type: 'group', 146 | displayName: 'Tag cloud', 147 | items: [ 148 | { 149 | type: 'slider', 150 | key: 'tagCloudMaxTags', 151 | displayName: 'Max tags to show', 152 | min: 10, 153 | max: 100, 154 | step: 10, 155 | default: 50 156 | }, 157 | { 158 | type: 'dropdown', 159 | key: 'tagCloudSortBy', 160 | displayName: 'Sort by', 161 | default: 'frequency', 162 | options: { 163 | frequency: 'Frequency', 164 | alphabetical: 'Alphabetical' 165 | } 166 | } 167 | ] 168 | } 169 | ] 170 | } 171 | -------------------------------------------------------------------------------- /src/app/view/column-config.service.ts: -------------------------------------------------------------------------------- 1 | import type { BasesPropertyId } from 'obsidian' 2 | import type { LifeTrackerPlugin } from '../plugin' 3 | import { 4 | VisualizationType, 5 | type PropertyVisualizationPreset, 6 | type ColumnConfigMap, 7 | type ColumnVisualizationConfig, 8 | type ScaleConfig, 9 | type EffectiveConfigResult 10 | } from '../types' 11 | import { log } from '../../utils' 12 | 13 | /** 14 | * Config key for storing column configurations in view config 15 | */ 16 | export const COLUMN_CONFIGS_KEY = 'columnConfigs' 17 | 18 | /** 19 | * Service for managing column visualization configurations. 20 | * Handles local overrides and global preset matching. 21 | */ 22 | export class ColumnConfigService { 23 | constructor( 24 | private plugin: LifeTrackerPlugin, 25 | private getConfigValue: (key: string) => unknown, 26 | private setConfigValue: (key: string, value: unknown) => void 27 | ) {} 28 | 29 | /** 30 | * Get stored column configurations from view config 31 | */ 32 | getColumnConfigs(): ColumnConfigMap { 33 | return (this.getConfigValue(COLUMN_CONFIGS_KEY) as ColumnConfigMap) ?? {} 34 | } 35 | 36 | /** 37 | * Save column configuration for a property 38 | */ 39 | saveColumnConfig( 40 | propertyId: BasesPropertyId, 41 | visualizationType: VisualizationType, 42 | displayName: string, 43 | scale?: ScaleConfig 44 | ): void { 45 | const configs = this.getColumnConfigs() 46 | const config: ColumnVisualizationConfig = { 47 | propertyId, 48 | visualizationType, 49 | displayName, 50 | configuredAt: Date.now() 51 | } 52 | if (scale) { 53 | config.scale = scale 54 | } 55 | configs[propertyId] = config 56 | this.setConfigValue(COLUMN_CONFIGS_KEY, configs) 57 | } 58 | 59 | /** 60 | * Get column config for a property (if exists as local override) 61 | */ 62 | getColumnConfig(propertyId: BasesPropertyId): ColumnVisualizationConfig | null { 63 | const configs = this.getColumnConfigs() 64 | return configs[propertyId] ?? null 65 | } 66 | 67 | /** 68 | * Find a matching global preset for a property 69 | * Matches against the raw property name (e.g., 'energy_level_evening') 70 | */ 71 | findMatchingPreset(propertyId: BasesPropertyId): PropertyVisualizationPreset | null { 72 | const presets = this.plugin.settings.visualizationPresets 73 | if (presets.length === 0) return null 74 | 75 | // Extract raw property name from ID (e.g., 'note.energy_level_evening' -> 'energy_level_evening') 76 | const rawPropertyName = propertyId.includes('.') 77 | ? propertyId.substring(propertyId.indexOf('.') + 1) 78 | : propertyId 79 | 80 | const lowerRawName = rawPropertyName.toLowerCase() 81 | 82 | log('Finding preset', 'debug', { 83 | propertyId, 84 | rawPropertyName, 85 | presetPatterns: presets.map((p) => p.propertyNamePattern) 86 | }) 87 | 88 | for (const preset of presets) { 89 | const patternLower = preset.propertyNamePattern.toLowerCase() 90 | 91 | if (patternLower === lowerRawName) { 92 | log('Preset matched', 'debug', { 93 | pattern: preset.propertyNamePattern, 94 | matchedTo: propertyId 95 | }) 96 | return preset 97 | } 98 | } 99 | 100 | return null 101 | } 102 | 103 | /** 104 | * Get effective configuration for a property 105 | * Priority: local override > global preset > null (unconfigured) 106 | */ 107 | getEffectiveConfig( 108 | propertyId: BasesPropertyId, 109 | displayName: string 110 | ): EffectiveConfigResult | null { 111 | // Check for local override first 112 | const localConfig = this.getColumnConfig(propertyId) 113 | if (localConfig) { 114 | return { config: localConfig, isFromPreset: false } 115 | } 116 | 117 | // Check for matching global preset 118 | const preset = this.findMatchingPreset(propertyId) 119 | if (preset) { 120 | // Create a config from the preset 121 | const configFromPreset: ColumnVisualizationConfig = { 122 | propertyId, 123 | visualizationType: preset.visualizationType, 124 | displayName, 125 | configuredAt: 0, // Not persisted 126 | scale: preset.scale 127 | } 128 | return { config: configFromPreset, isFromPreset: true } 129 | } 130 | 131 | return null 132 | } 133 | 134 | /** 135 | * Update an existing column configuration (local override) 136 | */ 137 | updateColumnConfig( 138 | propertyId: BasesPropertyId, 139 | updates: Partial 140 | ): void { 141 | const configs = this.getColumnConfigs() 142 | const existing = configs[propertyId] 143 | if (existing) { 144 | configs[propertyId] = { 145 | ...existing, 146 | ...updates, 147 | configuredAt: Date.now() 148 | } 149 | this.setConfigValue(COLUMN_CONFIGS_KEY, configs) 150 | } 151 | } 152 | 153 | /** 154 | * Delete a column configuration (reset to unconfigured state) 155 | */ 156 | deleteColumnConfig(propertyId: BasesPropertyId): void { 157 | const configs = this.getColumnConfigs() 158 | delete configs[propertyId] 159 | this.setConfigValue(COLUMN_CONFIGS_KEY, configs) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/app/components/ui/column-config-card.ts: -------------------------------------------------------------------------------- 1 | import { setIcon } from 'obsidian' 2 | import { 3 | CONFIG_CARD_VISUALIZATION_OPTIONS, 4 | SCALE_PRESETS, 5 | supportsScale, 6 | type ScaleConfig, 7 | type ColumnConfigCallback 8 | } from '../../types' 9 | 10 | /** 11 | * Creates a card prompting user to configure a column's visualization 12 | */ 13 | export function createColumnConfigCard( 14 | container: HTMLElement, 15 | propertyName: string, 16 | onSelect: ColumnConfigCallback 17 | ): HTMLElement { 18 | const card = container.createDiv({ cls: 'lt-card lt-config-card' }) 19 | 20 | // Header 21 | const header = card.createDiv({ cls: 'lt-config-card-header' }) 22 | header.createSpan({ cls: 'lt-config-card-title', text: propertyName }) 23 | const subtitle = header.createSpan({ 24 | cls: 'lt-config-card-subtitle', 25 | text: 'Select visualization type' 26 | }) 27 | 28 | // Options grid (shown first) 29 | const optionsGrid = card.createDiv({ cls: 'lt-config-card-options' }) 30 | 31 | // Scale config section (hidden initially) 32 | const scaleSection = card.createDiv({ cls: 'lt-config-scale-section lt-hidden' }) 33 | 34 | for (const option of CONFIG_CARD_VISUALIZATION_OPTIONS) { 35 | const optionBtn = optionsGrid.createDiv({ cls: 'lt-config-option' }) 36 | optionBtn.setAttribute('role', 'button') 37 | optionBtn.setAttribute('tabindex', '0') 38 | optionBtn.setAttribute('aria-label', `${option.label}: ${option.description}`) 39 | 40 | // Icon 41 | const iconEl = optionBtn.createDiv({ cls: 'lt-config-option-icon' }) 42 | setIcon(iconEl, option.icon) 43 | 44 | // Label 45 | optionBtn.createSpan({ cls: 'lt-config-option-label', text: option.label }) 46 | 47 | // Click handler 48 | const handleSelect = (): void => { 49 | if (supportsScale(option.type)) { 50 | // Show scale configuration 51 | optionsGrid.addClass('lt-hidden') 52 | subtitle.setText('Configure scale (optional)') 53 | renderScaleConfig(scaleSection, option.label, (scale) => { 54 | onSelect({ visualizationType: option.type, scale }) 55 | }) 56 | scaleSection.removeClass('lt-hidden') 57 | } else { 58 | // No scale config needed, submit directly 59 | onSelect({ visualizationType: option.type }) 60 | } 61 | } 62 | 63 | optionBtn.addEventListener('click', handleSelect) 64 | 65 | // Keyboard handler 66 | optionBtn.addEventListener('keydown', (e) => { 67 | if (e.key === 'Enter' || e.key === ' ') { 68 | e.preventDefault() 69 | handleSelect() 70 | } 71 | }) 72 | } 73 | 74 | return card 75 | } 76 | 77 | /** 78 | * Render scale configuration UI 79 | */ 80 | function renderScaleConfig( 81 | container: HTMLElement, 82 | vizLabel: string, 83 | onConfirm: (scale: ScaleConfig | undefined) => void 84 | ): void { 85 | container.empty() 86 | 87 | const form = container.createDiv({ cls: 'lt-scale-form' }) 88 | 89 | // Info text 90 | form.createDiv({ 91 | cls: 'lt-scale-info', 92 | text: `Set min/max for ${vizLabel}. Leave empty for auto-detection.` 93 | }) 94 | 95 | // Input row 96 | const inputRow = form.createDiv({ cls: 'lt-scale-inputs' }) 97 | 98 | // Min input 99 | const minGroup = inputRow.createDiv({ cls: 'lt-scale-input-group' }) 100 | minGroup.createSpan({ cls: 'lt-scale-label', text: 'Min' }) 101 | const minInput = minGroup.createEl('input', { 102 | cls: 'lt-scale-input', 103 | type: 'number', 104 | placeholder: 'auto' 105 | }) 106 | 107 | // Max input 108 | const maxGroup = inputRow.createDiv({ cls: 'lt-scale-input-group' }) 109 | maxGroup.createSpan({ cls: 'lt-scale-label', text: 'Max' }) 110 | const maxInput = maxGroup.createEl('input', { 111 | cls: 'lt-scale-input', 112 | type: 'number', 113 | placeholder: 'auto' 114 | }) 115 | 116 | // Preset buttons for common scales 117 | const presetsRow = form.createDiv({ cls: 'lt-scale-presets' }) 118 | presetsRow.createSpan({ cls: 'lt-scale-presets-label', text: 'Presets:' }) 119 | 120 | for (const preset of SCALE_PRESETS) { 121 | const btn = presetsRow.createEl('button', { 122 | cls: 'lt-scale-preset-btn', 123 | text: preset.label 124 | }) 125 | btn.addEventListener('click', () => { 126 | minInput.value = String(preset.min) 127 | maxInput.value = String(preset.max) 128 | }) 129 | } 130 | 131 | // Buttons row 132 | const buttonsRow = form.createDiv({ cls: 'lt-scale-buttons' }) 133 | 134 | const skipBtn = buttonsRow.createEl('button', { 135 | cls: 'lt-scale-btn lt-scale-btn--secondary', 136 | text: 'Skip (auto)' 137 | }) 138 | skipBtn.addEventListener('click', () => { 139 | onConfirm(undefined) 140 | }) 141 | 142 | const confirmBtn = buttonsRow.createEl('button', { 143 | cls: 'lt-scale-btn lt-scale-btn--primary', 144 | text: 'Confirm' 145 | }) 146 | confirmBtn.addEventListener('click', () => { 147 | const minVal = minInput.value.trim() 148 | const maxVal = maxInput.value.trim() 149 | 150 | const scale: ScaleConfig = { 151 | min: minVal ? parseFloat(minVal) : null, 152 | max: maxVal ? parseFloat(maxVal) : null 153 | } 154 | 155 | // Only pass scale if at least one value is set 156 | if (scale.min !== null || scale.max !== null) { 157 | onConfirm(scale) 158 | } else { 159 | onConfirm(undefined) 160 | } 161 | }) 162 | } 163 | -------------------------------------------------------------------------------- /src/app/view/maximize-state.service.ts: -------------------------------------------------------------------------------- 1 | import type { BasesPropertyId } from 'obsidian' 2 | import type { BaseVisualization } from '../components/visualizations/base-visualization' 3 | import type { GetDataPointsCallback } from '../types' 4 | import { CSS_SELECTOR, DATA_ATTR_FULL } from '../../utils' 5 | 6 | /** 7 | * Service for managing card maximize/minimize state. 8 | * Handles escape key listeners and DOM class updates. 9 | */ 10 | export class MaximizeStateService { 11 | private maximizedPropertyId: BasesPropertyId | null = null 12 | private escapeHandler: ((e: KeyboardEvent) => void) | null = null 13 | 14 | constructor( 15 | private containerEl: HTMLElement, 16 | private getGridEl: () => HTMLElement | null, 17 | private getVisualizations: () => Map< 18 | BasesPropertyId, 19 | { propertyDisplayName: string; visualization: BaseVisualization } 20 | >, 21 | private getDataPoints: GetDataPointsCallback 22 | ) {} 23 | 24 | /** 25 | * Get the currently maximized property ID 26 | */ 27 | getMaximizedPropertyId(): BasesPropertyId | null { 28 | return this.maximizedPropertyId 29 | } 30 | 31 | /** 32 | * Check if a property is currently maximized 33 | */ 34 | isMaximized(propertyId: BasesPropertyId): boolean { 35 | return this.maximizedPropertyId === propertyId 36 | } 37 | 38 | /** 39 | * Toggle maximize state for a property 40 | */ 41 | handleMaximizeToggle(propertyId: BasesPropertyId, maximize: boolean): void { 42 | const previousMaximized = this.maximizedPropertyId 43 | 44 | // Clean up any existing escape handler first 45 | if (this.escapeHandler) { 46 | document.removeEventListener('keydown', this.escapeHandler) 47 | this.escapeHandler = null 48 | } 49 | 50 | if (maximize) { 51 | this.maximizedPropertyId = propertyId 52 | 53 | // Add escape key handler - use arrow function that reads current state 54 | this.escapeHandler = (e: KeyboardEvent): void => { 55 | if (e.key === 'Escape' && this.maximizedPropertyId) { 56 | e.preventDefault() 57 | e.stopPropagation() 58 | this.handleMaximizeToggle(this.maximizedPropertyId, false) 59 | } 60 | } 61 | document.addEventListener('keydown', this.escapeHandler) 62 | 63 | // Add maximized class to container 64 | this.containerEl.classList.add('lt-container--has-maximized') 65 | } else { 66 | this.maximizedPropertyId = null 67 | 68 | // Remove maximized class from container 69 | this.containerEl.classList.remove('lt-container--has-maximized') 70 | } 71 | 72 | // Update visualization states 73 | const visualizations = this.getVisualizations() 74 | for (const [id, viz] of visualizations) { 75 | const isMaximized = id === this.maximizedPropertyId 76 | viz.visualization.setMaximized(isMaximized) 77 | } 78 | 79 | // Update card classes 80 | const gridEl = this.getGridEl() 81 | if (gridEl) { 82 | const cards = gridEl.querySelectorAll(CSS_SELECTOR.CARD) 83 | cards.forEach((card) => { 84 | const cardPropertyId = card.getAttribute(DATA_ATTR_FULL.PROPERTY_ID) 85 | 86 | // Skip unconfigured cards (those without data-property-id) - they never participate in maximize state 87 | if (!cardPropertyId) { 88 | // Ensure unconfigured cards are hidden when another card is maximized 89 | if (this.maximizedPropertyId) { 90 | card.classList.add('lt-card--hidden') 91 | } else { 92 | card.classList.remove('lt-card--hidden') 93 | } 94 | return 95 | } 96 | 97 | // Only configured cards with matching propertyId should be maximized 98 | if (this.maximizedPropertyId && cardPropertyId === this.maximizedPropertyId) { 99 | card.classList.add('lt-card--maximized') 100 | card.classList.remove('lt-card--hidden') 101 | } else { 102 | card.classList.remove('lt-card--maximized') 103 | if (this.maximizedPropertyId) { 104 | card.classList.add('lt-card--hidden') 105 | } else { 106 | card.classList.remove('lt-card--hidden') 107 | } 108 | } 109 | }) 110 | } 111 | 112 | // Re-render the maximized visualization to fit new size 113 | if (maximize) { 114 | const viz = visualizations.get(propertyId) 115 | if (viz && viz.visualization) { 116 | const dataPoints = this.getDataPoints(propertyId, viz.propertyDisplayName) 117 | viz.visualization.update(dataPoints) 118 | } 119 | } else if (previousMaximized) { 120 | // Re-render the previously maximized visualization 121 | const viz = visualizations.get(previousMaximized) 122 | if (viz && viz.visualization) { 123 | const dataPoints = this.getDataPoints(previousMaximized, viz.propertyDisplayName) 124 | viz.visualization.update(dataPoints) 125 | } 126 | } 127 | } 128 | 129 | /** 130 | * Clean up maximize state and handlers 131 | */ 132 | cleanup(): void { 133 | if (this.escapeHandler) { 134 | document.removeEventListener('keydown', this.escapeHandler) 135 | this.escapeHandler = null 136 | } 137 | this.maximizedPropertyId = null 138 | this.containerEl.classList.remove('lt-container--has-maximized') 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/app/types/visualization/visualization-options.intf.ts: -------------------------------------------------------------------------------- 1 | import { VisualizationType } from './visualization-type.intf' 2 | 3 | /** 4 | * Visualization option for context menu 5 | */ 6 | export interface ContextMenuVisualizationOption { 7 | type: VisualizationType 8 | label: string 9 | icon: string 10 | } 11 | 12 | /** 13 | * Visualization option for config card (includes description) 14 | */ 15 | export interface ConfigCardVisualizationOption { 16 | type: VisualizationType 17 | label: string 18 | icon: string 19 | description: string 20 | } 21 | 22 | /** 23 | * Visualization options for context menu 24 | * Used by card-context-menu.ts 25 | */ 26 | export const CONTEXT_MENU_VISUALIZATION_OPTIONS: ContextMenuVisualizationOption[] = [ 27 | { type: VisualizationType.Heatmap, label: 'Heatmap', icon: 'flame' }, 28 | { type: VisualizationType.BarChart, label: 'Bar chart', icon: 'bar-chart-2' }, 29 | { type: VisualizationType.LineChart, label: 'Line chart', icon: 'trending-up' }, 30 | { type: VisualizationType.AreaChart, label: 'Area chart', icon: 'area-chart' }, 31 | { type: VisualizationType.PieChart, label: 'Pie chart', icon: 'pie-chart' }, 32 | { type: VisualizationType.DoughnutChart, label: 'Doughnut chart', icon: 'circle' }, 33 | { type: VisualizationType.RadarChart, label: 'Radar chart', icon: 'hexagon' }, 34 | { type: VisualizationType.PolarAreaChart, label: 'Polar area chart', icon: 'target' }, 35 | { type: VisualizationType.ScatterChart, label: 'Scatter chart', icon: 'scatter-chart' }, 36 | { type: VisualizationType.BubbleChart, label: 'Bubble chart', icon: 'circle-dot' }, 37 | { type: VisualizationType.TagCloud, label: 'Cloud', icon: 'cloud' }, 38 | { type: VisualizationType.Timeline, label: 'Timeline', icon: 'calendar' } 39 | ] 40 | 41 | /** 42 | * Visualization options for config card (property configuration UI) 43 | * Used by column-config-card.ts 44 | */ 45 | export const CONFIG_CARD_VISUALIZATION_OPTIONS: ConfigCardVisualizationOption[] = [ 46 | { 47 | type: VisualizationType.Heatmap, 48 | label: 'Heatmap', 49 | icon: 'flame', 50 | description: 'GitHub-style intensity grid' 51 | }, 52 | { 53 | type: VisualizationType.BarChart, 54 | label: 'Bar chart', 55 | icon: 'bar-chart-2', 56 | description: 'Vertical bars over time' 57 | }, 58 | { 59 | type: VisualizationType.LineChart, 60 | label: 'Line chart', 61 | icon: 'trending-up', 62 | description: 'Connected line over time' 63 | }, 64 | { 65 | type: VisualizationType.AreaChart, 66 | label: 'Area chart', 67 | icon: 'area-chart', 68 | description: 'Filled area under line' 69 | }, 70 | { 71 | type: VisualizationType.PieChart, 72 | label: 'Pie chart', 73 | icon: 'pie-chart', 74 | description: 'Value distribution as slices' 75 | }, 76 | { 77 | type: VisualizationType.DoughnutChart, 78 | label: 'Doughnut chart', 79 | icon: 'circle', 80 | description: 'Pie chart with center cutout' 81 | }, 82 | { 83 | type: VisualizationType.RadarChart, 84 | label: 'Radar chart', 85 | icon: 'hexagon', 86 | description: 'Multi-axis comparison' 87 | }, 88 | { 89 | type: VisualizationType.PolarAreaChart, 90 | label: 'Polar area chart', 91 | icon: 'target', 92 | description: 'Radial segments by value' 93 | }, 94 | { 95 | type: VisualizationType.ScatterChart, 96 | label: 'Scatter chart', 97 | icon: 'scatter-chart', 98 | description: 'Individual data points' 99 | }, 100 | { 101 | type: VisualizationType.BubbleChart, 102 | label: 'Bubble chart', 103 | icon: 'circle-dot', 104 | description: 'Points with size dimension' 105 | }, 106 | { 107 | type: VisualizationType.TagCloud, 108 | label: 'Cloud', 109 | icon: 'cloud', 110 | description: 'Frequency-sized items' 111 | }, 112 | { 113 | type: VisualizationType.Timeline, 114 | label: 'Timeline', 115 | icon: 'calendar', 116 | description: 'Date distribution' 117 | } 118 | ] 119 | 120 | /** 121 | * Visualization options for settings tab dropdown 122 | * Used by settings-tab.ts 123 | */ 124 | export const SETTINGS_TAB_VISUALIZATION_OPTIONS: Record = { 125 | [VisualizationType.Heatmap]: 'Heatmap', 126 | [VisualizationType.BarChart]: 'Bar chart', 127 | [VisualizationType.LineChart]: 'Line chart', 128 | [VisualizationType.AreaChart]: 'Area chart', 129 | [VisualizationType.PieChart]: 'Pie chart', 130 | [VisualizationType.DoughnutChart]: 'Doughnut chart', 131 | [VisualizationType.RadarChart]: 'Radar chart', 132 | [VisualizationType.PolarAreaChart]: 'Polar area chart', 133 | [VisualizationType.ScatterChart]: 'Scatter chart', 134 | [VisualizationType.BubbleChart]: 'Bubble chart', 135 | [VisualizationType.TagCloud]: 'Cloud', 136 | [VisualizationType.Timeline]: 'Timeline' 137 | } 138 | 139 | /** 140 | * Scale preset for numeric visualizations 141 | */ 142 | export interface ScalePreset { 143 | label: string 144 | min: number 145 | max: number 146 | } 147 | 148 | /** 149 | * Common scale presets for numeric visualizations 150 | * Used by card-context-menu.ts, column-config-card.ts, and settings-tab.ts 151 | */ 152 | export const SCALE_PRESETS: ScalePreset[] = [ 153 | { label: '0-1', min: 0, max: 1 }, 154 | { label: '0-5', min: 0, max: 5 }, 155 | { label: '1-5', min: 1, max: 5 }, 156 | { label: '0-10', min: 0, max: 10 }, 157 | { label: '1-10', min: 1, max: 10 }, 158 | { label: '0-100', min: 0, max: 100 } 159 | ] 160 | 161 | /** 162 | * Scale presets as a Record (used by settings-tab.ts dropdown) 163 | */ 164 | export const SCALE_PRESETS_RECORD: Record = { 165 | auto: null, 166 | ...Object.fromEntries(SCALE_PRESETS.map((p) => [p.label, { min: p.min, max: p.max }])) 167 | } 168 | -------------------------------------------------------------------------------- /src/app/components/editing/dirty-state.service.ts: -------------------------------------------------------------------------------- 1 | import type { DirtyChangeCallback } from '../../types' 2 | 3 | /** 4 | * State for a single tracked entry 5 | */ 6 | interface EntryState { 7 | entryId: string 8 | originalValues: Record 9 | currentValues: Record 10 | } 11 | 12 | /** 13 | * Service to track unsaved changes for entries (notes) 14 | * Used by table and grid views to manage Save/Reset button state 15 | */ 16 | export class DirtyStateService { 17 | private entries = new Map() 18 | private listeners = new Set() 19 | 20 | /** 21 | * Start tracking an entry with its initial values 22 | * @param entryId Unique identifier (typically file path) 23 | * @param values Initial property values 24 | */ 25 | track(entryId: string, values: Record): void { 26 | this.entries.set(entryId, { 27 | entryId, 28 | originalValues: this.deepClone(values), 29 | currentValues: this.deepClone(values) 30 | }) 31 | } 32 | 33 | /** 34 | * Stop tracking an entry 35 | */ 36 | untrack(entryId: string): void { 37 | this.entries.delete(entryId) 38 | } 39 | 40 | /** 41 | * Clear all tracked entries 42 | */ 43 | clear(): void { 44 | this.entries.clear() 45 | } 46 | 47 | /** 48 | * Update a single property value 49 | */ 50 | update(entryId: string, propertyName: string, value: unknown): void { 51 | const entry = this.entries.get(entryId) 52 | if (!entry) return 53 | 54 | entry.currentValues[propertyName] = value 55 | this.notifyListeners(entryId) 56 | } 57 | 58 | /** 59 | * Update multiple property values at once 60 | */ 61 | updateMultiple(entryId: string, values: Record): void { 62 | const entry = this.entries.get(entryId) 63 | if (!entry) return 64 | 65 | for (const [key, value] of Object.entries(values)) { 66 | entry.currentValues[key] = value 67 | } 68 | this.notifyListeners(entryId) 69 | } 70 | 71 | /** 72 | * Check if an entry has unsaved changes 73 | */ 74 | isDirty(entryId: string): boolean { 75 | const entry = this.entries.get(entryId) 76 | if (!entry) return false 77 | 78 | return !this.deepEqual(entry.originalValues, entry.currentValues) 79 | } 80 | 81 | /** 82 | * Check if a specific property has changed 83 | */ 84 | isPropertyDirty(entryId: string, propertyName: string): boolean { 85 | const entry = this.entries.get(entryId) 86 | if (!entry) return false 87 | 88 | return !this.deepEqual( 89 | entry.originalValues[propertyName], 90 | entry.currentValues[propertyName] 91 | ) 92 | } 93 | 94 | /** 95 | * Get all current values for an entry 96 | */ 97 | getCurrentValues(entryId: string): Record | null { 98 | const entry = this.entries.get(entryId) 99 | if (!entry) return null 100 | 101 | return this.deepClone(entry.currentValues) 102 | } 103 | 104 | /** 105 | * Get only the changed values for saving 106 | * Returns null if no changes 107 | */ 108 | getChanges(entryId: string): Record | null { 109 | const entry = this.entries.get(entryId) 110 | if (!entry || !this.isDirty(entryId)) return null 111 | 112 | const changes: Record = {} 113 | for (const [key, value] of Object.entries(entry.currentValues)) { 114 | if (!this.deepEqual(entry.originalValues[key], value)) { 115 | changes[key] = value 116 | } 117 | } 118 | 119 | return Object.keys(changes).length > 0 ? changes : null 120 | } 121 | 122 | /** 123 | * Reset entry to original values 124 | * Returns the original values 125 | */ 126 | reset(entryId: string): Record | null { 127 | const entry = this.entries.get(entryId) 128 | if (!entry) return null 129 | 130 | entry.currentValues = this.deepClone(entry.originalValues) 131 | this.notifyListeners(entryId) 132 | return this.deepClone(entry.originalValues) 133 | } 134 | 135 | /** 136 | * Mark entry as saved (set original = current) 137 | */ 138 | markSaved(entryId: string): void { 139 | const entry = this.entries.get(entryId) 140 | if (!entry) return 141 | 142 | entry.originalValues = this.deepClone(entry.currentValues) 143 | this.notifyListeners(entryId) 144 | } 145 | 146 | /** 147 | * Subscribe to dirty state changes 148 | * @returns Unsubscribe function 149 | */ 150 | onChange(callback: DirtyChangeCallback): () => void { 151 | this.listeners.add(callback) 152 | return () => { 153 | this.listeners.delete(callback) 154 | } 155 | } 156 | 157 | /** 158 | * Get list of all dirty entry IDs 159 | */ 160 | getDirtyEntryIds(): string[] { 161 | const dirtyIds: string[] = [] 162 | for (const entryId of this.entries.keys()) { 163 | if (this.isDirty(entryId)) { 164 | dirtyIds.push(entryId) 165 | } 166 | } 167 | return dirtyIds 168 | } 169 | 170 | /** 171 | * Check if any entries are dirty 172 | */ 173 | hasAnyDirty(): boolean { 174 | for (const entryId of this.entries.keys()) { 175 | if (this.isDirty(entryId)) { 176 | return true 177 | } 178 | } 179 | return false 180 | } 181 | 182 | private notifyListeners(entryId: string): void { 183 | const isDirty = this.isDirty(entryId) 184 | for (const listener of this.listeners) { 185 | try { 186 | listener(entryId, isDirty) 187 | } catch { 188 | // Ignore listener errors 189 | } 190 | } 191 | } 192 | 193 | private deepClone(obj: T): T { 194 | return JSON.parse(JSON.stringify(obj)) as T 195 | } 196 | 197 | private deepEqual(a: unknown, b: unknown): boolean { 198 | return JSON.stringify(a) === JSON.stringify(b) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/app/types/visualization/visualization.types.ts: -------------------------------------------------------------------------------- 1 | import type { BasesPropertyId } from 'obsidian' 2 | import type { TimeGranularity } from './time-granularity.intf' 3 | import type { ResolvedDateAnchor } from '../view/date-anchor.types' 4 | import type { ScaleConfig } from '../column/column-config.types' 5 | 6 | /** 7 | * A single data point for visualization. 8 | * All data is pre-extracted and cleaned - no raw Obsidian values or entries. 9 | */ 10 | export interface VisualizationDataPoint { 11 | /** File path for navigation (to open the source file) */ 12 | filePath: string 13 | /** Resolved date anchor (null if no date could be determined) */ 14 | dateAnchor: ResolvedDateAnchor | null 15 | /** Extracted numeric value (null if not numeric or empty) */ 16 | numericValue: number | null 17 | /** Extracted boolean value (null if not a boolean) */ 18 | booleanValue: boolean | null 19 | /** Extracted display label (null if empty/no data) */ 20 | displayLabel: string | null 21 | /** Extracted list/tag values for tag cloud visualization (empty array if not a list) */ 22 | listValues: string[] 23 | } 24 | 25 | /** 26 | * Aggregated data for heatmap visualization 27 | */ 28 | export interface HeatmapData { 29 | propertyId: BasesPropertyId 30 | displayName: string 31 | granularity: TimeGranularity 32 | cells: HeatmapCell[] 33 | minDate: Date 34 | maxDate: Date 35 | minValue: number 36 | maxValue: number 37 | } 38 | 39 | /** 40 | * Single cell in a heatmap 41 | */ 42 | export interface HeatmapCell { 43 | date: Date 44 | value: number | null 45 | count: number 46 | filePaths: string[] 47 | } 48 | 49 | /** 50 | * Aggregated data for chart visualization 51 | */ 52 | export interface ChartData { 53 | propertyId: BasesPropertyId 54 | displayName: string 55 | labels: string[] 56 | datasets: ChartDataset[] 57 | } 58 | 59 | /** 60 | * Dataset for chart visualization 61 | */ 62 | export interface ChartDataset { 63 | label: string 64 | data: (number | null)[] 65 | filePaths: string[][] 66 | } 67 | 68 | /** 69 | * Aggregated data for pie/doughnut chart visualization 70 | * Shows distribution of values 71 | */ 72 | export interface PieChartData { 73 | propertyId: BasesPropertyId 74 | displayName: string 75 | labels: string[] 76 | values: number[] 77 | filePaths: string[][] 78 | /** Whether the data represents boolean values (for color coding) */ 79 | isBooleanData: boolean 80 | } 81 | 82 | /** 83 | * Single point for scatter chart 84 | */ 85 | export interface ScatterPoint { 86 | x: number 87 | y: number 88 | } 89 | 90 | /** 91 | * Single point for bubble chart (includes radius) 92 | */ 93 | export interface BubblePoint { 94 | x: number 95 | y: number 96 | r: number 97 | } 98 | 99 | /** 100 | * Aggregated data for scatter chart visualization 101 | * Shows correlation between time (x) and value (y) 102 | */ 103 | export interface ScatterChartData { 104 | propertyId: BasesPropertyId 105 | displayName: string 106 | points: ScatterPoint[] 107 | filePaths: string[] 108 | } 109 | 110 | /** 111 | * Aggregated data for bubble chart visualization 112 | * Shows time (x), value (y), and count (r) 113 | */ 114 | export interface BubbleChartData { 115 | propertyId: BasesPropertyId 116 | displayName: string 117 | points: BubblePoint[] 118 | filePaths: string[][] 119 | } 120 | 121 | /** 122 | * Aggregated data for tag cloud visualization 123 | */ 124 | export interface TagCloudData { 125 | propertyId: BasesPropertyId 126 | displayName: string 127 | tags: TagCloudItem[] 128 | maxFrequency: number 129 | } 130 | 131 | /** 132 | * Single tag item in tag cloud 133 | */ 134 | export interface TagCloudItem { 135 | tag: string 136 | frequency: number 137 | filePaths: string[] 138 | } 139 | 140 | /** 141 | * Aggregated data for timeline visualization 142 | */ 143 | export interface TimelineData { 144 | propertyId: BasesPropertyId 145 | displayName: string 146 | points: TimelinePoint[] 147 | minDate: Date 148 | maxDate: Date 149 | } 150 | 151 | /** 152 | * Single point on timeline 153 | */ 154 | export interface TimelinePoint { 155 | date: Date 156 | label: string 157 | value: number | null 158 | filePaths: string[] 159 | } 160 | 161 | /** 162 | * Heatmap color scheme configuration 163 | */ 164 | export interface HeatmapColorScheme { 165 | empty: string 166 | levels: [string, string, string, string, string] 167 | } 168 | 169 | /** 170 | * Configuration for visualization rendering 171 | */ 172 | export interface VisualizationConfig { 173 | granularity: TimeGranularity 174 | /** Show empty values: includes dates with no entries AND dates where property value is null/empty */ 175 | showEmptyValues: boolean 176 | embeddedHeight: number 177 | } 178 | 179 | /** 180 | * Heatmap-specific configuration 181 | */ 182 | export interface HeatmapConfig extends VisualizationConfig { 183 | colorScheme: HeatmapColorScheme 184 | cellSize: number 185 | cellGap: number 186 | showMonthLabels: boolean 187 | showDayLabels: boolean 188 | /** Optional scale configuration for value normalization */ 189 | scale?: ScaleConfig 190 | } 191 | 192 | /** 193 | * Supported Chart.js chart types 194 | */ 195 | export type ChartJsType = 196 | | 'line' 197 | | 'bar' 198 | | 'pie' 199 | | 'doughnut' 200 | | 'radar' 201 | | 'polarArea' 202 | | 'scatter' 203 | | 'bubble' 204 | 205 | /** 206 | * Chart-specific configuration 207 | */ 208 | export interface ChartConfig extends VisualizationConfig { 209 | chartType: ChartJsType 210 | showLegend: boolean 211 | showGrid: boolean 212 | tension: number 213 | /** Whether to fill area under line (for line/area charts) */ 214 | fill?: boolean 215 | /** Optional scale configuration for Y-axis */ 216 | scale?: ScaleConfig 217 | /** For pie/doughnut: whether to aggregate by value distribution */ 218 | aggregateByValue?: boolean 219 | } 220 | 221 | /** 222 | * Tag cloud-specific configuration 223 | */ 224 | export interface TagCloudConfig extends VisualizationConfig { 225 | minFontSize: number 226 | maxFontSize: number 227 | sortBy: 'frequency' | 'alphabetical' 228 | maxTags: number 229 | } 230 | -------------------------------------------------------------------------------- /src/app/components/visualizations/tag-cloud/tag-cloud-visualization.ts: -------------------------------------------------------------------------------- 1 | import type { App, BasesPropertyId } from 'obsidian' 2 | import { BaseVisualization } from '../base-visualization' 3 | import type { TagCloudConfig, TagCloudData, VisualizationDataPoint } from '../../../types' 4 | import { DataAggregationService } from '../../../services/data-aggregation.service' 5 | import { Tooltip, formatTagTooltip } from '../../ui/tooltip' 6 | import { log } from '../../../../utils' 7 | 8 | /** 9 | * Shared aggregation service instance for all tag cloud visualizations 10 | */ 11 | const sharedAggregationService = new DataAggregationService() 12 | 13 | /** 14 | * Tag cloud visualization for tags and lists 15 | */ 16 | export class TagCloudVisualization extends BaseVisualization { 17 | private tagCloudConfig: TagCloudConfig 18 | private tooltip: Tooltip | null = null 19 | private cloudEl: HTMLElement | null = null 20 | private tagCloudData: TagCloudData | null = null 21 | 22 | constructor( 23 | containerEl: HTMLElement, 24 | app: App, 25 | propertyId: BasesPropertyId, 26 | displayName: string, 27 | config: TagCloudConfig 28 | ) { 29 | super(containerEl, app, propertyId, displayName, config) 30 | this.tagCloudConfig = config 31 | } 32 | 33 | /** 34 | * Render the tag cloud with data 35 | */ 36 | override render(data: VisualizationDataPoint[]): void { 37 | log(`Rendering tag cloud for ${this.displayName}`, 'debug') 38 | 39 | // Aggregate data (use shared service) 40 | this.tagCloudData = sharedAggregationService.aggregateForTagCloud( 41 | data, 42 | this.propertyId, 43 | this.displayName 44 | ) 45 | 46 | if (this.tagCloudData.tags.length === 0) { 47 | this.showEmptyState(`No data found for "${this.displayName}"`) 48 | return 49 | } 50 | 51 | // Clear container 52 | this.containerEl.empty() 53 | 54 | // Create section header 55 | this.createSectionHeader(this.displayName) 56 | 57 | // Create tag cloud container 58 | this.cloudEl = this.containerEl.createDiv({ cls: 'lt-tag-cloud' }) 59 | 60 | // Create tooltip 61 | this.tooltip = new Tooltip(this.cloudEl) 62 | 63 | // Sort tags based on config 64 | let sortedTags = [...this.tagCloudData.tags] 65 | if (this.tagCloudConfig.sortBy === 'alphabetical') { 66 | sortedTags.sort((a, b) => a.tag.localeCompare(b.tag)) 67 | } 68 | // Already sorted by frequency from aggregation service 69 | 70 | // Limit to max tags 71 | sortedTags = sortedTags.slice(0, this.tagCloudConfig.maxTags) 72 | 73 | // Render tags 74 | for (const tagItem of sortedTags) { 75 | const fontSize = this.calculateFontSize( 76 | tagItem.frequency, 77 | this.tagCloudData.maxFrequency 78 | ) 79 | const sizeClass = this.getSizeClass(fontSize) 80 | 81 | const tagEl = this.cloudEl.createSpan({ 82 | cls: `lt-tag ${sizeClass}`, 83 | text: tagItem.tag 84 | }) 85 | 86 | // Store data 87 | tagEl.dataset['tag'] = tagItem.tag 88 | tagEl.dataset['frequency'] = String(tagItem.frequency) 89 | 90 | // Add event listeners 91 | tagEl.addEventListener('mouseenter', (e) => this.handleTagHover(e, tagEl)) 92 | tagEl.addEventListener('mouseleave', () => this.handleTagLeave()) 93 | tagEl.addEventListener('click', () => this.handleTagClick(tagItem.tag)) 94 | } 95 | } 96 | 97 | /** 98 | * Update the tag cloud with new data 99 | */ 100 | override update(data: VisualizationDataPoint[]): void { 101 | // Re-render for simplicity 102 | this.render(data) 103 | } 104 | 105 | /** 106 | * Clean up resources 107 | */ 108 | override destroy(): void { 109 | this.tooltip?.destroy() 110 | this.tooltip = null 111 | this.cloudEl = null 112 | this.tagCloudData = null 113 | } 114 | 115 | /** 116 | * Calculate font size based on frequency 117 | */ 118 | private calculateFontSize(frequency: number, maxFrequency: number): number { 119 | if (maxFrequency === 0) return this.tagCloudConfig.minFontSize 120 | 121 | const ratio = frequency / maxFrequency 122 | const range = this.tagCloudConfig.maxFontSize - this.tagCloudConfig.minFontSize 123 | return this.tagCloudConfig.minFontSize + ratio * range 124 | } 125 | 126 | /** 127 | * Get CSS class for size 128 | */ 129 | private getSizeClass(fontSize: number): string { 130 | const range = this.tagCloudConfig.maxFontSize - this.tagCloudConfig.minFontSize 131 | const step = range / 5 132 | 133 | if (fontSize < this.tagCloudConfig.minFontSize + step) return 'lt-tag--xs' 134 | if (fontSize < this.tagCloudConfig.minFontSize + step * 2) return 'lt-tag--sm' 135 | if (fontSize < this.tagCloudConfig.minFontSize + step * 3) return 'lt-tag--md' 136 | if (fontSize < this.tagCloudConfig.minFontSize + step * 4) return 'lt-tag--lg' 137 | return 'lt-tag--xl' 138 | } 139 | 140 | /** 141 | * Handle tag hover - show tooltip 142 | */ 143 | private handleTagHover(_event: MouseEvent, tagEl: HTMLElement): void { 144 | if (!this.tooltip) return 145 | 146 | const tag = tagEl.dataset['tag'] ?? '' 147 | const frequencyStr = tagEl.dataset['frequency'] 148 | const frequency = frequencyStr ? parseInt(frequencyStr, 10) : 0 149 | 150 | const { title, value } = formatTagTooltip(tag, frequency) 151 | 152 | const rect = tagEl.getBoundingClientRect() 153 | this.tooltip.show(rect.left + rect.width / 2, rect.top - 10, title, value) 154 | } 155 | 156 | /** 157 | * Handle tag leave - hide tooltip 158 | */ 159 | private handleTagLeave(): void { 160 | this.tooltip?.hide() 161 | } 162 | 163 | /** 164 | * Handle tag click - open related files 165 | */ 166 | private handleTagClick(tag: string): void { 167 | if (!this.tagCloudData) return 168 | 169 | const tagItem = this.tagCloudData.tags.find((t) => t.tag === tag) 170 | if (tagItem && tagItem.filePaths.length > 0) { 171 | this.openFilePaths(tagItem.filePaths) 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /documentation/Architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | ## Overview 4 | 5 | Obsidian plugin providing a custom Base View ("Life Tracker") for visualizing tracked data. 6 | 7 | ## Directory Structure 8 | 9 | ``` 10 | src/ 11 | main.ts # Re-export only 12 | app/ 13 | plugin.ts # Plugin lifecycle, settings management, view registration 14 | settings/ 15 | settings-tab.ts # Plugin settings UI 16 | types/ 17 | plugin-settings.intf.ts # PluginSettings, PropertyVisualizationPreset 18 | property-definition.types.ts # PropertyDefinition, PropertyType, NumberConstraint 19 | column-config.types.ts # ColumnVisualizationConfig, ScaleConfig 20 | visualization-type.intf.ts # VisualizationType enum 21 | time-granularity.intf.ts # TimeGranularity enum 22 | visualization-options.intf.ts # UI options for menus/cards 23 | visualization.types.ts # Data structures for visualizations 24 | date-anchor.types.ts # Date anchor types 25 | commands/ 26 | index.ts # Command registration 27 | capture-command.ts # Property capture command 28 | services/ 29 | date-anchor.service.ts # Extract dates from entries (filename, properties) 30 | data-aggregation.service.ts # Aggregate data for visualizations 31 | frontmatter.service.ts # Read/write frontmatter properties 32 | chart-aggregation.utils.ts # Chart-specific aggregation 33 | date-grouping.utils.ts # Time period grouping 34 | view/ 35 | life-tracker-view.ts # Main BasesView (visualizations) 36 | table-view/ 37 | table-view.ts # Table BasesView (spreadsheet editing) 38 | table-view-options.ts # Table view options 39 | grid-view/ 40 | grid-view.ts # Grid BasesView (card editing) 41 | grid-view-options.ts # Grid view options 42 | view-options.ts # Life tracker view options 43 | column-config.service.ts # Per-column config management 44 | maximize-state.service.ts # Card maximize/minimize state 45 | visualization-config.helper.ts # Build visualization configs 46 | components/ 47 | ui/ 48 | card-context-menu.ts # Right-click menu 49 | column-config-card.ts # Unconfigured property card 50 | grid-controls.ts # Column/height controls 51 | empty-state.ts # No data states 52 | tooltip.ts # Shared tooltip 53 | editing/ 54 | property-editor.ts # Property editor factory 55 | text-editor.ts # Text/dropdown editor 56 | number-editor.ts # Number/slider editor 57 | boolean-editor.ts # Toggle/checkbox editor 58 | date-editor.ts # Date/datetime editor 59 | list-editor.ts # Pill/chip list editor 60 | validation.utils.ts # Validation functions 61 | dirty-state.service.ts # Track unsaved changes 62 | modals/ 63 | property-capture-modal.ts # Capture/edit modal 64 | visualizations/ 65 | base-visualization.ts # Abstract base class 66 | heatmap/ # GitHub-style heatmap 67 | chart/ # Chart.js wrapper (line, bar, area, pie, etc.) 68 | tag-cloud/ # Frequency-sized tags 69 | timeline/ # Date distribution 70 | utils/ 71 | date-utils.ts # Date parsing, formatting 72 | color-utils.ts # Heatmap color scales 73 | value-extractors.ts # Extract values from Obsidian Value types 74 | log.ts # Debug logging 75 | styles.src.css # Tailwind source (compiled to styles.css) 76 | ``` 77 | 78 | ## Key Components 79 | 80 | ### Plugin (`plugin.ts`) 81 | 82 | - Registers three BasesViews: life-tracker, life-tracker-table, life-tracker-grid 83 | - Manages immutable settings with immer 84 | - Notifies views on settings changes 85 | - Registers capture command 86 | 87 | ### Base Views 88 | 89 | #### LifeTrackerView (`view/life-tracker-view.ts`) 90 | 91 | - Extends `BasesView` 92 | - Renders grid of visualization cards 93 | - Delegates to services for data processing 94 | - Creates visualization instances per property 95 | 96 | #### TableView (`view/table-view/table-view.ts`) 97 | 98 | - Spreadsheet-style editing interface 99 | - One row per note, one column per property definition 100 | - Inline editors for all property types 101 | - Per-row Save/Reset buttons (disabled when clean) 102 | - Highlights rows with missing/invalid values 103 | 104 | #### GridView (`view/grid-view/grid-view.ts`) 105 | 106 | - Card-based editing interface 107 | - One card per note with all property fields 108 | - Full-size editors (not compact) 109 | - Per-card Save/Reset buttons 110 | - Visual indicators for dirty/invalid state 111 | 112 | ### Property Editors 113 | 114 | Factory pattern (`property-editor.ts`) creates type-specific editors: 115 | 116 | - **TextEditor**: Plain input or dropdown if `allowedValues` defined 117 | - **NumberEditor**: Number input with optional slider for ranges 118 | - **BooleanEditor**: Toggle switch or compact checkbox 119 | - **DateEditor**: Native date/datetime-local picker 120 | - **ListEditor**: Pill/chip interface with autocomplete suggestions 121 | 122 | All editors: 123 | 124 | - Validate input against property definitions 125 | - Report changes via `onChange` callback 126 | - Support compact mode for table cells 127 | 128 | ### Services 129 | 130 | - **DateAnchorService**: Resolves date for each entry (filename pattern > property > file metadata) 131 | - **DataAggregationService**: Groups data by time granularity, produces visualization-ready structures 132 | - **FrontmatterService**: Read/write frontmatter, validate against property definitions 133 | - **ColumnConfigService**: Manages per-property visualization configs (persisted in view config) 134 | - **MaximizeStateService**: Handles card maximize/minimize state, escape key handler 135 | 136 | ### Visualizations 137 | 138 | All extend `BaseVisualization`: 139 | 140 | - `HeatmapVisualization`: GitHub contribution-style grid 141 | - `ChartVisualization`: Chart.js wrapper for 9 chart types 142 | - `TagCloudVisualization`: Frequency-weighted tags 143 | - `TimelineVisualization`: Horizontal date distribution 144 | 145 | ## Data Flow 146 | 147 | ``` 148 | BasesEntry[] → DateAnchorService → DataAggregationService → Visualization.render() 149 | ``` 150 | 151 | 1. Entries from Obsidian Bases API 152 | 2. Date anchors resolved per entry 153 | 3. Data aggregated by granularity 154 | 4. Visualization renders to DOM 155 | -------------------------------------------------------------------------------- /src/utils/value.utils.ts: -------------------------------------------------------------------------------- 1 | import { Value } from 'obsidian' 2 | import { parseISO, isValid, parse } from 'date-fns' 3 | 4 | /** 5 | * Common date formats to try when parsing dates. 6 | * Order matters - more specific/common formats first. 7 | */ 8 | const DATE_FORMATS = [ 9 | // ISO-like formats (most common in Obsidian) 10 | 'yyyy-MM-dd', 11 | 'yyyy/MM/dd', 12 | // European formats (day first) 13 | 'dd-MM-yyyy', 14 | 'dd/MM/yyyy', 15 | 'dd.MM.yyyy', 16 | // US formats (month first) 17 | 'MM-dd-yyyy', 18 | 'MM/dd/yyyy', 19 | // With time 20 | 'yyyy-MM-dd HH:mm:ss', 21 | 'yyyy-MM-dd HH:mm', 22 | "yyyy-MM-dd'T'HH:mm:ss", 23 | "yyyy-MM-dd'T'HH:mm", 24 | 'dd-MM-yyyy HH:mm:ss', 25 | 'dd/MM/yyyy HH:mm:ss', 26 | 'MM-dd-yyyy HH:mm:ss', 27 | 'MM/dd/yyyy HH:mm:ss' 28 | ] 29 | 30 | /** 31 | * Extract a display label from any value (Obsidian Value, object, array, primitive). 32 | * Returns null if the value has no meaningful displayable content. 33 | * 34 | * @param value - The value to extract a label from 35 | * @returns Display label string, or null if the value should be skipped 36 | */ 37 | export function extractDisplayLabel( 38 | propertyDisplayName: string, 39 | numericValue: number | null, 40 | booleanValue: boolean | null, 41 | listValues: string[] 42 | ): string | null { 43 | if (numericValue === null && booleanValue === null && listValues.length === 0) { 44 | return null 45 | } 46 | 47 | if (booleanValue) { 48 | if (booleanValue === true) { 49 | return `${propertyDisplayName}: True` 50 | } else { 51 | return `${propertyDisplayName}: False` 52 | } 53 | } 54 | 55 | if (listValues.length > 0) { 56 | return `${propertyDisplayName}: ${listValues.join(', ')}` 57 | } 58 | 59 | return `${propertyDisplayName}: ${numericValue}` 60 | } 61 | 62 | /** 63 | * Extract number value. 64 | * Returns null if the value is not numeric. 65 | * Note: Boolean values are NOT converted to numbers - use extractBoolean instead. 66 | */ 67 | export function extractNumber(value: unknown): number | null { 68 | if (value === null || value === undefined) return null 69 | 70 | // If already a number, return it 71 | if (typeof value === 'number') { 72 | return isNaN(value) ? null : value 73 | } 74 | 75 | // If it's a boolean, don't convert - let booleanValue handle it 76 | if (typeof value === 'boolean') { 77 | return null 78 | } 79 | 80 | const str = value.toString().trim() 81 | if (!str) return null 82 | 83 | // Check if it's a boolean-like string - don't convert 84 | const lower = str.toLowerCase() 85 | if (lower === 'true' || lower === 'false' || lower === 'yes' || lower === 'no') { 86 | return null 87 | } 88 | 89 | // Try to parse as number 90 | const num = parseFloat(str) 91 | if (!isNaN(num)) { 92 | return num 93 | } 94 | 95 | return null 96 | } 97 | 98 | /** 99 | * Extract boolean value. 100 | * Returns true/false for boolean values, null otherwise. 101 | */ 102 | export function extractBoolean(value: unknown): boolean | null { 103 | if (value === null || value === undefined) return null 104 | 105 | // If already a boolean, return it 106 | if (typeof value === 'boolean') { 107 | return value 108 | } 109 | 110 | // If it's a number, don't convert - let numericValue handle it 111 | if (typeof value === 'number') { 112 | return null 113 | } 114 | 115 | const str = value.toString().trim().toLowerCase() 116 | if (!str || str === 'null' || str === 'undefined') return null 117 | 118 | // Check for boolean-like strings 119 | if (str === 'true' || str === 'yes') return true 120 | if (str === 'false' || str === 'no') return false 121 | 122 | return null 123 | } 124 | 125 | /** 126 | * Parse a date string using multiple format attempts. 127 | * Returns the parsed Date or null if no format matches. 128 | */ 129 | function parseDateString(str: string): Date | null { 130 | // Trim whitespace 131 | const trimmed = str.trim() 132 | if (!trimmed) return null 133 | 134 | // Try ISO parsing first (handles full ISO 8601 with timezone) 135 | const isoDate = parseISO(trimmed) 136 | if (isValid(isoDate)) { 137 | return isoDate 138 | } 139 | 140 | // Try each format in order 141 | const referenceDate = new Date() 142 | for (const format of DATE_FORMATS) { 143 | try { 144 | const parsed = parse(trimmed, format, referenceDate) 145 | if (isValid(parsed)) { 146 | return parsed 147 | } 148 | } catch { 149 | // Format didn't match, continue to next 150 | } 151 | } 152 | 153 | return null 154 | } 155 | 156 | /** 157 | * Extract date value from Obsidian Value type 158 | */ 159 | export function extractDate(value: Value | null): Date | null { 160 | if (!value) return null 161 | 162 | const str = value.toString() 163 | return parseDateString(str) 164 | } 165 | 166 | /** 167 | * Extract list/array from Obsidian Value type 168 | * Works with ListValue and comma-separated strings 169 | */ 170 | export function extractList(value: Value | null): string[] { 171 | if (!value) return [] 172 | 173 | const str = value.toString() 174 | 175 | // Handle array-like strings: ["a", "b"] or [a, b] 176 | if (str.startsWith('[') && str.endsWith(']')) { 177 | const inner = str.slice(1, -1) 178 | return parseListItems(inner) 179 | } 180 | 181 | // Handle comma-separated values 182 | if (str.includes(',')) { 183 | return parseListItems(str) 184 | } 185 | 186 | // Single value 187 | const trimmed = str.trim() 188 | return trimmed ? [trimmed] : [] 189 | } 190 | 191 | /** 192 | * Parse comma-separated list items 193 | */ 194 | function parseListItems(str: string): string[] { 195 | return str 196 | .split(',') 197 | .map((item) => item.trim().replace(/^["']|["']$/g, '')) 198 | .filter((item) => item.length > 0) 199 | } 200 | 201 | /** 202 | * Check if a value appears to be a date 203 | */ 204 | export function isDateLike(value: Value | null): boolean { 205 | if (!value) return false 206 | 207 | const str = value.toString() 208 | 209 | // Quick regex check for common date patterns before expensive parsing 210 | // Matches: YYYY-MM-DD, DD-MM-YYYY, MM-DD-YYYY, DD/MM/YYYY, etc. 211 | if (/^\d{2,4}[-/.]\d{2}[-/.]\d{2,4}/.test(str)) { 212 | // Looks like a date, try parsing 213 | return parseDateString(str) !== null 214 | } 215 | 216 | // Try parsing anyway for other formats 217 | return parseDateString(str) !== null 218 | } 219 | -------------------------------------------------------------------------------- /src/app/services/date-anchor.service.ts: -------------------------------------------------------------------------------- 1 | import type { BasesEntry, BasesPropertyId, Value } from 'obsidian' 2 | import type { DateAnchorConfig, DateAnchorSource, ResolvedDateAnchor } from '../types' 3 | import { parseDateFromFilename, extractDate, isDateLike } from '../../utils' 4 | 5 | /** 6 | * Service for resolving date anchors from entries 7 | */ 8 | export class DateAnchorService { 9 | /** 10 | * Default date anchor sources in priority order 11 | */ 12 | private readonly defaultSources: DateAnchorConfig[] = [ 13 | { source: { type: 'filename', pattern: 'auto' }, priority: 1 }, 14 | { source: { type: 'property', propertyId: 'note.date' as BasesPropertyId }, priority: 2 }, 15 | { 16 | source: { type: 'property', propertyId: 'note.created' as BasesPropertyId }, 17 | priority: 3 18 | }, 19 | { source: { type: 'file-metadata', field: 'ctime' }, priority: 4 } 20 | ] 21 | 22 | /** 23 | * Resolve date anchor for an entry using default sources 24 | */ 25 | resolveAnchor(entry: BasesEntry, config?: DateAnchorConfig[]): ResolvedDateAnchor | null { 26 | const sources = config ?? this.defaultSources 27 | 28 | // Sort by priority 29 | const sortedSources = [...sources].sort((a, b) => a.priority - b.priority) 30 | 31 | for (const { source } of sortedSources) { 32 | const result = this.tryResolveFromSource(entry, source) 33 | if (result) { 34 | return result 35 | } 36 | } 37 | 38 | return null 39 | } 40 | 41 | /** 42 | * Try to resolve date from a specific source 43 | */ 44 | private tryResolveFromSource( 45 | entry: BasesEntry, 46 | source: DateAnchorSource 47 | ): ResolvedDateAnchor | null { 48 | switch (source.type) { 49 | case 'filename': 50 | return this.resolveFromFilename(entry, source) 51 | 52 | case 'property': 53 | return this.resolveFromProperty(entry, source) 54 | 55 | case 'file-metadata': 56 | return this.resolveFromFileMetadata(entry, source) 57 | 58 | default: 59 | return null 60 | } 61 | } 62 | 63 | /** 64 | * Resolve date from filename 65 | */ 66 | private resolveFromFilename( 67 | entry: BasesEntry, 68 | source: DateAnchorSource & { type: 'filename' } 69 | ): ResolvedDateAnchor | null { 70 | const filename = entry.file.basename 71 | 72 | const parsed = parseDateFromFilename(filename) 73 | if (parsed) { 74 | return { 75 | date: parsed.date, 76 | source, 77 | confidence: 'high' 78 | } 79 | } 80 | 81 | return null 82 | } 83 | 84 | /** 85 | * Resolve date from a property value 86 | */ 87 | private resolveFromProperty( 88 | entry: BasesEntry, 89 | source: DateAnchorSource & { type: 'property' } 90 | ): ResolvedDateAnchor | null { 91 | const value = entry.getValue(source.propertyId) 92 | 93 | if (!value || !isDateLike(value)) { 94 | return null 95 | } 96 | 97 | const date = extractDate(value) 98 | if (date) { 99 | return { 100 | date, 101 | source, 102 | confidence: 'medium' 103 | } 104 | } 105 | 106 | return null 107 | } 108 | 109 | /** 110 | * Resolve date from file metadata 111 | */ 112 | private resolveFromFileMetadata( 113 | entry: BasesEntry, 114 | source: DateAnchorSource & { type: 'file-metadata' } 115 | ): ResolvedDateAnchor | null { 116 | const stat = entry.file.stat 117 | 118 | let timestamp: number | undefined 119 | if (source.field === 'ctime') { 120 | timestamp = stat.ctime 121 | } else if (source.field === 'mtime') { 122 | timestamp = stat.mtime 123 | } 124 | 125 | if (timestamp) { 126 | return { 127 | date: new Date(timestamp), 128 | source, 129 | confidence: 'low' 130 | } 131 | } 132 | 133 | return null 134 | } 135 | 136 | /** 137 | * Resolve date anchors for all entries 138 | */ 139 | resolveAllAnchors( 140 | entries: BasesEntry[], 141 | config?: DateAnchorConfig[] 142 | ): Map { 143 | const results = new Map() 144 | 145 | for (const entry of entries) { 146 | results.set(entry, this.resolveAnchor(entry, config)) 147 | } 148 | 149 | return results 150 | } 151 | 152 | /** 153 | * Find available date properties in entries 154 | */ 155 | findDateProperties(entries: BasesEntry[], propertyIds: BasesPropertyId[]): BasesPropertyId[] { 156 | const dateProperties: BasesPropertyId[] = [] 157 | 158 | for (const propId of propertyIds) { 159 | // Skip file properties for date anchor (use file-metadata source instead) 160 | if (propId.startsWith('file.')) continue 161 | 162 | // Check if any entry has a date-like value for this property 163 | let hasDateValue = false 164 | for (const entry of entries) { 165 | const value: Value | null = entry.getValue(propId) 166 | if (isDateLike(value)) { 167 | hasDateValue = true 168 | break 169 | } 170 | } 171 | 172 | if (hasDateValue) { 173 | dateProperties.push(propId) 174 | } 175 | } 176 | 177 | return dateProperties 178 | } 179 | 180 | /** 181 | * Create config for a specific property 182 | */ 183 | createPropertyConfig(propertyId: BasesPropertyId, priority: number): DateAnchorConfig { 184 | return { 185 | source: { type: 'property', propertyId }, 186 | priority 187 | } 188 | } 189 | 190 | /** 191 | * Create config for filename-based anchoring 192 | */ 193 | createFilenameConfig(priority: number): DateAnchorConfig { 194 | return { 195 | source: { type: 'filename', pattern: 'auto' }, 196 | priority 197 | } 198 | } 199 | 200 | /** 201 | * Create config for file metadata anchoring 202 | */ 203 | createMetadataConfig(field: 'ctime' | 'mtime', priority: number): DateAnchorConfig { 204 | return { 205 | source: { type: 'file-metadata', field }, 206 | priority 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/app/services/property-recognition.service.ts: -------------------------------------------------------------------------------- 1 | import { type TFile, parseFrontMatterTags, type CachedMetadata, type App } from 'obsidian' 2 | import type { PropertyDefinition, Mapping } from '../types' 3 | 4 | /** 5 | * Service responsible for determining which property definitions apply to a file. 6 | * 7 | * Recognition uses OR logic: If a property has multiple mappings, ANY enabled 8 | * mapping matching means the property applies to that file. 9 | * 10 | * Properties with no mappings apply to all files. 11 | * 12 | * Compatible with Obsidian Starter Kit plugin's recognition patterns. 13 | */ 14 | export class PropertyRecognitionService { 15 | private cache: Map 16 | 17 | constructor(private app: App) { 18 | this.cache = new Map() 19 | } 20 | 21 | /** 22 | * Get all property definitions that apply to a specific file. 23 | * 24 | * @param file - The file to check 25 | * @param definitions - All property definitions 26 | * @returns Property definitions that apply to this file 27 | */ 28 | getApplicableProperties(file: TFile, definitions: PropertyDefinition[]): PropertyDefinition[] { 29 | // Check cache first 30 | const cacheKey = this.getCacheKey(file.path, definitions) 31 | const cached = this.cache.get(cacheKey) 32 | if (cached) { 33 | return cached 34 | } 35 | 36 | const metadata = this.app.metadataCache.getFileCache(file) 37 | 38 | const applicable = definitions.filter((def) => this.propertyApplies(file, metadata, def)) 39 | 40 | // Cache result 41 | this.cache.set(cacheKey, applicable) 42 | 43 | return applicable 44 | } 45 | 46 | /** 47 | * Check if a property definition applies to a file. 48 | * A property applies if it has no mappings OR if any enabled mapping matches. 49 | * 50 | * @param file - The file to check 51 | * @param definition - The property definition 52 | * @returns True if the property applies to this file 53 | */ 54 | propertyAppliesToFile(file: TFile, definition: PropertyDefinition): boolean { 55 | const metadata = this.app.metadataCache.getFileCache(file) 56 | return this.propertyApplies(file, metadata, definition) 57 | } 58 | 59 | /** 60 | * Check if a property applies to a file. 61 | * Internal implementation with metadata already retrieved. 62 | */ 63 | private propertyApplies( 64 | file: TFile, 65 | metadata: CachedMetadata | null, 66 | definition: PropertyDefinition 67 | ): boolean { 68 | // If no mappings defined, property applies to all files 69 | const enabledMappings = definition.mappings.filter((m) => m.enabled) 70 | if (enabledMappings.length === 0) { 71 | return true 72 | } 73 | 74 | // OR logic: if ANY enabled mapping matches, property applies 75 | return enabledMappings.some((mapping) => this.matchesMapping(file, metadata, mapping)) 76 | } 77 | 78 | /** 79 | * Check if a file matches a specific mapping. 80 | */ 81 | private matchesMapping( 82 | file: TFile, 83 | metadata: CachedMetadata | null, 84 | mapping: Mapping 85 | ): boolean { 86 | switch (mapping.type) { 87 | case 'tag': 88 | return this.matchesTag(metadata, mapping.value) 89 | 90 | case 'folder': 91 | return this.matchesFolder(file, mapping.value) 92 | 93 | case 'regex': 94 | return this.matchesRegex(file, mapping.value) 95 | 96 | case 'formula': 97 | // Formula matching not yet implemented 98 | return false 99 | 100 | default: { 101 | // Exhaustive check 102 | const _exhaustive: never = mapping.type 103 | console.error('Unknown mapping type:', _exhaustive) 104 | return false 105 | } 106 | } 107 | } 108 | 109 | /** 110 | * Check if a file has a specific tag in its frontmatter. 111 | */ 112 | private matchesTag(metadata: CachedMetadata | null, tag: string): boolean { 113 | if (!metadata?.frontmatter) { 114 | return false 115 | } 116 | 117 | const tags = parseFrontMatterTags(metadata.frontmatter) 118 | if (!tags) { 119 | return false 120 | } 121 | 122 | // Normalize tag (remove # prefix if present) 123 | const normalizedTag = tag.startsWith('#') ? tag.slice(1) : tag 124 | 125 | return tags.some((t) => { 126 | const normalized = t.startsWith('#') ? t.slice(1) : t 127 | return normalized.toLowerCase() === normalizedTag.toLowerCase() 128 | }) 129 | } 130 | 131 | /** 132 | * Check if a file is in a specific folder. 133 | */ 134 | private matchesFolder(file: TFile, folderPath: string): boolean { 135 | // Normalize folder path (remove leading/trailing slashes) 136 | const normalizedFolder = folderPath.replace(/^\/|\/$/g, '') 137 | 138 | // Get file's folder path 139 | const fileFolder = file.parent?.path || '' 140 | 141 | // Exact match or subfolder match 142 | return ( 143 | fileFolder.toLowerCase() === normalizedFolder.toLowerCase() || 144 | fileFolder.toLowerCase().startsWith(normalizedFolder.toLowerCase() + '/') 145 | ) 146 | } 147 | 148 | /** 149 | * Check if a file path or name matches a regex pattern. 150 | */ 151 | private matchesRegex(file: TFile, pattern: string): boolean { 152 | try { 153 | const regex = new RegExp(pattern, 'i') 154 | // Test against both full path and basename 155 | return regex.test(file.path) || regex.test(file.basename) 156 | } catch (e) { 157 | console.error('Invalid regex pattern:', pattern, e) 158 | return false 159 | } 160 | } 161 | 162 | /** 163 | * Create a cache key from file path and definitions. 164 | * We include a hash of definition mappings to invalidate on changes. 165 | */ 166 | private getCacheKey(filePath: string, definitions: PropertyDefinition[]): string { 167 | const mappingsHash = definitions 168 | .map( 169 | (d) => `${d.id}:${d.mappings.length}:${d.mappings.filter((m) => m.enabled).length}` 170 | ) 171 | .join('|') 172 | return `${filePath}:${mappingsHash}` 173 | } 174 | 175 | /** 176 | * Clear the recognition cache. 177 | * Should be called when property definitions change. 178 | * 179 | * @param path - Optional file path to clear (clears all if omitted) 180 | */ 181 | clearCache(path?: string): void { 182 | if (path) { 183 | // Clear all entries for this file path 184 | for (const key of this.cache.keys()) { 185 | if (key.startsWith(`${path}:`)) { 186 | this.cache.delete(key) 187 | } 188 | } 189 | } else { 190 | this.cache.clear() 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/app/types/property/property-definition.types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Property types supported by Obsidian's property system. 3 | * 4 | * NOTE: Compatible with Obsidian Starter Kit plugin's ObsidianPropertyType. 5 | * Uses 'checkbox' instead of 'boolean' to match Obsidian's internal naming. 6 | * 7 | * @see https://help.obsidian.md/Editing+and+formatting/Properties 8 | */ 9 | export type ObsidianPropertyType = 10 | | 'text' // Single-line text input 11 | | 'list' // Array of text values (multi-line list) 12 | | 'number' // Numeric value 13 | | 'checkbox' // Boolean (true/false) 14 | | 'date' // Date in YYYY-MM-DD format 15 | | 'datetime' // Date with time 16 | | 'tags' // Array of tags (special rendering in Obsidian) 17 | 18 | /** 19 | * Alias for backwards compatibility 20 | * @deprecated Use ObsidianPropertyType instead 21 | */ 22 | export type PropertyType = ObsidianPropertyType 23 | 24 | /** 25 | * All available property types 26 | */ 27 | export const PROPERTY_TYPES: ObsidianPropertyType[] = [ 28 | 'text', 29 | 'number', 30 | 'checkbox', 31 | 'date', 32 | 'datetime', 33 | 'list', 34 | 'tags' 35 | ] 36 | 37 | /** 38 | * Human-readable labels for property types 39 | */ 40 | export const PROPERTY_TYPE_LABELS: Record = { 41 | text: 'Text', 42 | number: 'Number', 43 | checkbox: 'Checkbox', 44 | date: 'Date', 45 | datetime: 'Date & Time', 46 | list: 'List', 47 | tags: 'Tags' 48 | } 49 | 50 | /** 51 | * Mapping types for property filtering/recognition. 52 | * Compatible with Obsidian Starter Kit plugin's MappingType. 53 | */ 54 | export type MappingType = 'tag' | 'folder' | 'regex' | 'formula' 55 | 56 | /** 57 | * Defines how properties are associated with specific notes. 58 | * Compatible with Obsidian Starter Kit plugin's Mapping interface. 59 | * 60 | * A property matches a note if it matches ANY enabled mapping (OR logic). 61 | * 62 | * @example 63 | * // Tag-based filtering - property applies to notes with #meeting tag 64 | * { type: 'tag', value: 'meeting', enabled: true } 65 | * 66 | * @example 67 | * // Folder-based filtering - property applies to notes in Personal/Meetings 68 | * { type: 'folder', value: 'Personal/Meetings', enabled: true } 69 | * 70 | * @example 71 | * // Regex pattern - property applies to daily notes 72 | * { type: 'regex', value: '^\\d{4}-\\d{2}-\\d{2}$', enabled: true } 73 | */ 74 | export interface Mapping { 75 | /** Type of mapping strategy */ 76 | type: MappingType 77 | /** Value/pattern to match against */ 78 | value: string 79 | /** Whether this mapping is active */ 80 | enabled: boolean 81 | } 82 | 83 | /** 84 | * Number range for numeric property validation. 85 | * Compatible with Obsidian Starter Kit plugin's NumberRange. 86 | */ 87 | export interface NumberRange { 88 | min: number 89 | max: number 90 | } 91 | 92 | /** 93 | * Valid default values for Obsidian properties. 94 | * Compatible with Obsidian Starter Kit plugin's PropertyDefaultValue. 95 | */ 96 | export type PropertyDefaultValue = string | number | boolean | string[] | null 97 | 98 | /** 99 | * Valid allowed values for Obsidian properties. 100 | * Compatible with Obsidian Starter Kit plugin's PropertyAllowedValues. 101 | */ 102 | export type PropertyAllowedValues = string[] | number[] 103 | 104 | /** 105 | * Definition of a trackable property. 106 | * Compatible with Obsidian Starter Kit plugin's PropertyDefinition. 107 | * 108 | * Stored in plugin settings and used across all views. 109 | * Properties can be filtered to specific notes via mappings. 110 | * 111 | * @example 112 | * // Required date property with default 113 | * { 114 | * id: 'prop-1704670800000-abc123', 115 | * name: 'meeting_date', 116 | * displayName: 'Meeting Date', 117 | * type: 'date', 118 | * required: true, 119 | * defaultValue: null, 120 | * description: 'When the meeting occurred', 121 | * mappings: [{ type: 'tag', value: 'meeting', enabled: true }] 122 | * } 123 | * 124 | * @example 125 | * // Enum-like text property with allowed values 126 | * { 127 | * id: 'prop-1704670800001-def456', 128 | * name: 'status', 129 | * displayName: 'Status', 130 | * type: 'text', 131 | * required: true, 132 | * allowedValues: ['scheduled', 'completed', 'cancelled'], 133 | * defaultValue: 'scheduled', 134 | * description: 'Current meeting status', 135 | * mappings: [] 136 | * } 137 | */ 138 | export interface PropertyDefinition { 139 | /** Unique ID (UUID) - Life Tracker extension */ 140 | id: string 141 | /** Property name (as it appears in frontmatter) */ 142 | name: string 143 | /** Display name for UI */ 144 | displayName: string 145 | /** Property type */ 146 | type: ObsidianPropertyType 147 | /** List of allowed values (empty array if not constrained) */ 148 | allowedValues: PropertyAllowedValues 149 | /** Number range for numeric properties (null if not constrained by range) */ 150 | numberRange: NumberRange | null 151 | /** Default value when auto-adding properties (null if no default) */ 152 | defaultValue: PropertyDefaultValue 153 | /** Whether this property is required */ 154 | required: boolean 155 | /** Description (empty string if not set) */ 156 | description: string 157 | /** Display order (lower = first) - Life Tracker extension */ 158 | order: number 159 | /** Mappings to filter which notes this property applies to (empty = all notes) - Life Tracker extension */ 160 | mappings: Mapping[] 161 | } 162 | 163 | /** 164 | * Issue found with a property value 165 | */ 166 | export interface PropertyIssue { 167 | /** Name of the property with the issue */ 168 | propertyName: string 169 | /** Type of issue */ 170 | type: 'missing' | 'invalid' 171 | /** Human-readable message */ 172 | message: string 173 | } 174 | 175 | /** 176 | * Result of validating a property value 177 | */ 178 | export interface ValidationResult { 179 | /** Whether the value is valid */ 180 | valid: boolean 181 | /** Error message if invalid */ 182 | error?: string 183 | } 184 | 185 | /** 186 | * Create a new property definition with default values. 187 | * Compatible with Obsidian Starter Kit plugin format. 188 | */ 189 | export function createDefaultPropertyDefinition(id: string, order: number): PropertyDefinition { 190 | return { 191 | id, 192 | name: '', 193 | displayName: '', 194 | type: 'text', 195 | allowedValues: [], 196 | numberRange: null, 197 | defaultValue: null, 198 | required: false, 199 | description: '', 200 | order, 201 | mappings: [] 202 | } 203 | } 204 | 205 | /** 206 | * Get the display label for a property definition. 207 | * Falls back to name if displayName is empty. 208 | */ 209 | export function getPropertyDisplayLabel(definition: PropertyDefinition): string { 210 | return definition.displayName || definition.name 211 | } 212 | 213 | /** 214 | * Human-readable labels for mapping types 215 | */ 216 | export const MAPPING_TYPE_LABELS: Record = { 217 | tag: 'Tag', 218 | folder: 'Folder', 219 | regex: 'Regex', 220 | formula: 'Formula' 221 | } 222 | 223 | /** 224 | * Create a new mapping with default values 225 | */ 226 | export function createDefaultMapping(type: MappingType = 'folder'): Mapping { 227 | return { 228 | type, 229 | value: '', 230 | enabled: true 231 | } 232 | } 233 | --------------------------------------------------------------------------------