diff --git a/README.md b/README.md index cc2ec69..614babe 100644 --- a/README.md +++ b/README.md @@ -79,13 +79,14 @@ Optionally, the plugin can generate a **post-session report** - a Markdown note The plugin can write the following properties to frontmatter (configurable per-cohort, all optional, names customisable): -- Rating +- Glicko rating - Rank (1 = highest within the cohort) - Matches - Wins - Uncertainty (sigma) +- Star rating (rank or rating normalised to an n-star scale) -You can then use the values computed by the plugin however you want. For example, enable the Rank property and then sort a Base by it to see your notes in ranked order, or use the Rating property to filter for your best notes with a rating above a certain threshold. +You can then use the values computed by the plugin however you want. For example, enable the Rank property and then sort a Base by it to see your notes in ranked order, or use the Glicko rating property to filter for your best notes with a rating above a certain threshold. **Tip:** Configure global defaults in Settings, and (optionally) set per-cohort overrides when creating or editing a cohort. diff --git a/src/commands/ResetNoteRating.ts b/src/commands/ResetNoteRating.ts index c28454e..ce72010 100644 --- a/src/commands/ResetNoteRating.ts +++ b/src/commands/ResetNoteRating.ts @@ -9,7 +9,8 @@ import { ConfirmModal } from '../ui/ConfirmModal'; import { ALL_SENTINEL, ResetNoteModal } from '../ui/ResetNoteModal'; import { computeRanksForAll, - updateCohortFrontmatter, + computeStarsForAll, + refreshCohortRankAndStars, writeFrontmatterStatsForPlayer, } from '../utils/FrontmatterStats'; import { getNoteId } from '../utils/NoteIds'; @@ -89,26 +90,16 @@ export async function resetNoteRating( ); const rankMap = computeRanksForAll(cohort); + const wantStars = !!fm.stars.enabled && !!fm.stars.property; + const starMap = wantStars ? computeStarsForAll(cohort, fm.stars) : undefined; - // Write the reset note's own stats (rating, matches, wins, rank) - await writeFrontmatterStatsForPlayer(app, fm, cohort, file, noteId, rankMap); - - // Update rank across the whole cohort since relative ordering changed - const rankCfg = fm.rank; - if (!rankCfg.enabled || !rankCfg.property) continue; + // Write the reset note's own stats (rating, matches, wins, rank, stars) + await writeFrontmatterStatsForPlayer(app, fm, cohort, file, noteId, rankMap, starMap); + // Update rank/stars across the whole cohort since relative ordering changed const cohortFiles = await resolveFilesForCohort(app, def, { excludeFolderPath: settings.templatesFolderPath, }); - if (cohortFiles.length === 0) continue; - await updateCohortFrontmatter( - app, - cohortFiles, - rankMap, - rankCfg.property, - undefined, - 'Updating ranks...', - settings.idPropertyName, - ); + await refreshCohortRankAndStars(app, fm, cohort, cohortFiles, settings.idPropertyName, rankMap); } } diff --git a/src/main.ts b/src/main.ts index dab5b72..7e38793 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,7 @@ import { CohortPicker } from './ui/CohortPicker'; import { ensureBaseCohortTarget } from './utils/EnsureBaseCohort'; import { ensureFolderCohortPath } from './utils/EnsureFolderCohort'; import { ensureUniqueIds } from './utils/EnsureUniqueIds'; -import { computeRanksForAll, updateCohortFrontmatter } from './utils/FrontmatterStats'; +import { refreshCohortRankAndStars } from './utils/FrontmatterStats'; import { debugWarn, setDebugLogging } from './utils/logger'; export default class GlickoPlugin extends Plugin { @@ -266,26 +266,14 @@ export default class GlickoPlugin extends Plugin { this.settings.frontmatterProperties, def.frontmatterOverrides, ); - const rankCfg = fm.rank; - if (!rankCfg.enabled || !rankCfg.property) return; - const files = await resolveFilesForCohort(this.app, def, { excludeFolderPath: this.settings.templatesFolderPath, }); - if (files.length === 0) return; - const rankMap = computeRanksForAll(cohort); - - updateCohortFrontmatter( - this.app, - files, - rankMap, - rankCfg.property, - undefined, - 'Updating ranks in frontmatter...', - this.settings.idPropertyName, - ).catch((e) => { - console.error('[Glicko] Failed to update ranks in frontmatter', e); - }); + refreshCohortRankAndStars(this.app, fm, cohort, files, this.settings.idPropertyName).catch( + (e) => { + console.error('[Glicko] Failed to refresh cohort frontmatter', e); + }, + ); } } diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts index 2b68a02..c9e7651 100644 --- a/src/settings/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -9,16 +9,30 @@ import { CohortPicker } from '../ui/CohortPicker'; import { ConfirmModal } from '../ui/ConfirmModal'; import { computeRanksForAll, + computeStarsForAll, previewCohortFrontmatterPropertyUpdates, updateCohortFrontmatter, } from '../utils/FrontmatterStats'; import { applyIdTransferPlan, planIdTransfer } from '../utils/IdTransfer'; import { withNotice } from '../utils/safe'; -import type { FrontmatterPropertiesSettings, IdLocation } from './settings'; -import { DEFAULT_SETTINGS, effectiveFrontmatterProperties } from './settings'; +import type { FmSimpleKey } from './frontmatterCopy'; +import { + FM_SIMPLE_COPY, + FM_SIMPLE_KEYS, + FM_STARS_COPY, + PROPERTY_NAME_LABEL, + STAR_MAPPING_LABELS, + STAR_MODE_LABELS, +} from './frontmatterCopy'; +import type { IdLocation } from './settings'; +import { + DEFAULT_SETTINGS, + effectiveFrontmatterProperties, + starScaleConfigEquals, +} from './settings'; import { migrateIdPropertyName } from './SettingsTabMigration'; -type PropKey = keyof FrontmatterPropertiesSettings; +type PropKey = FmSimpleKey; function getByPath(root: unknown, path: string): unknown { const parts = path.split('.'); @@ -46,50 +60,6 @@ function setByPath(root: Record, path: string, value: unknown): cur[parts[parts.length - 1]] = value; } -const FM_KEY_META: Record< - PropKey, - { group: string; toggleName: string; toggleDesc: string; nameDesc: string; defaultProp: string } -> = { - rating: { - group: 'Rating', - toggleName: 'Write rating to frontmatter', - toggleDesc: 'Write the current Glicko rating to a frontmatter property.', - nameDesc: 'Frontmatter property used to store the rating.', - defaultProp: DEFAULT_SETTINGS.frontmatterProperties.rating.property, - }, - uncertainty: { - group: 'Uncertainty', - toggleName: 'Write uncertainty to frontmatter', - toggleDesc: - 'Write how uncertain the rating is. Starts high and decreases as more comparisons are made.', - nameDesc: 'Frontmatter property used to store the uncertainty.', - defaultProp: DEFAULT_SETTINGS.frontmatterProperties.uncertainty.property, - }, - rank: { - group: 'Rank', - toggleName: 'Write rank to frontmatter', - toggleDesc: 'Write the cohort rank (1 = highest) to a frontmatter property.', - nameDesc: 'Frontmatter property used to store the rank.', - defaultProp: DEFAULT_SETTINGS.frontmatterProperties.rank.property, - }, - matches: { - group: 'Matches', - toggleName: 'Write match count to frontmatter', - toggleDesc: 'Write the number of matches played to a frontmatter property.', - nameDesc: 'Frontmatter property used to store the match count.', - defaultProp: DEFAULT_SETTINGS.frontmatterProperties.matches.property, - }, - wins: { - group: 'Wins', - toggleName: 'Write win count to frontmatter', - toggleDesc: 'Write the number of wins to a frontmatter property.', - nameDesc: 'Frontmatter property used to store the win count.', - defaultProp: DEFAULT_SETTINGS.frontmatterProperties.wins.property, - }, -}; - -const FM_KEYS_ORDERED: readonly PropKey[] = ['rating', 'uncertainty', 'rank', 'matches', 'wins']; - export default class GlickoSettingsTab extends PluginSettingTab { icon = 'trophy'; plugin: GlickoPlugin; @@ -391,27 +361,27 @@ export default class GlickoSettingsTab extends PluginSettingTab { }, ]; - for (const key of FM_KEYS_ORDERED) { - const meta = FM_KEY_META[key]; + for (const key of FM_SIMPLE_KEYS) { + const copy = FM_SIMPLE_COPY[key]; items.push({ type: 'group', - heading: meta.group, + heading: copy.label, items: [ { - name: meta.toggleName, - desc: meta.toggleDesc, + name: copy.toggleName, + desc: copy.toggleDesc, control: { type: 'toggle', key: `frontmatterProperties.${key}.enabled`, }, }, { - name: 'Property name', - desc: meta.nameDesc, + name: PROPERTY_NAME_LABEL, + desc: copy.nameDesc, control: { type: 'text', key: `frontmatterProperties.${key}.property`, - placeholder: meta.defaultProp, + placeholder: DEFAULT_SETTINGS.frontmatterProperties[key].property, disabled: () => !this.plugin.settings.frontmatterProperties[key].enabled, }, }, @@ -419,6 +389,87 @@ export default class GlickoSettingsTab extends PluginSettingTab { }); } + const starsEnabled = () => this.plugin.settings.frontmatterProperties.stars.enabled; + const starFields = FM_STARS_COPY.fields; + items.push({ + type: 'group', + heading: FM_STARS_COPY.label, + items: [ + { + name: FM_STARS_COPY.toggleName, + desc: FM_STARS_COPY.toggleDesc, + control: { type: 'toggle', key: 'frontmatterProperties.stars.enabled' }, + }, + { + name: PROPERTY_NAME_LABEL, + desc: FM_STARS_COPY.nameDesc, + control: { + type: 'text', + key: 'frontmatterProperties.stars.property', + placeholder: DEFAULT_SETTINGS.frontmatterProperties.stars.property, + disabled: () => !starsEnabled(), + }, + }, + { + name: starFields.max.name, + desc: starFields.max.desc, + control: { + type: 'number', + key: 'frontmatterProperties.stars.max', + min: 2, + step: 1, + placeholder: String(DEFAULT_SETTINGS.frontmatterProperties.stars.max), + disabled: () => !starsEnabled(), + }, + }, + { + name: starFields.allowZero.name, + desc: starFields.allowZero.desc, + control: { + type: 'toggle', + key: 'frontmatterProperties.stars.allowZero', + disabled: () => !starsEnabled(), + }, + }, + { + name: starFields.mode.name, + desc: starFields.mode.desc, + control: { + type: 'dropdown', + key: 'frontmatterProperties.stars.mode', + defaultValue: DEFAULT_SETTINGS.frontmatterProperties.stars.mode, + options: STAR_MODE_LABELS, + disabled: () => !starsEnabled(), + }, + }, + { + name: starFields.decimals.name, + desc: starFields.decimals.desc, + control: { + type: 'number', + key: 'frontmatterProperties.stars.decimals', + min: 1, + max: 2, + step: 1, + disabled: () => !starsEnabled(), + }, + visible: () => + starsEnabled() && this.plugin.settings.frontmatterProperties.stars.mode === 'float', + }, + { + name: starFields.mapping.name, + desc: starFields.mapping.desc, + control: { + type: 'dropdown', + key: 'frontmatterProperties.stars.mapping', + defaultValue: DEFAULT_SETTINGS.frontmatterProperties.stars.mapping, + options: STAR_MAPPING_LABELS, + disabled: () => !starsEnabled(), + }, + }, + ], + }); + return items; } @@ -598,7 +649,11 @@ export default class GlickoSettingsTab extends PluginSettingTab { } } - if (changed.length === 0) return; + const starsRelevant = + (oldEffective.stars.enabled || newEffective.stars.enabled) && + !starScaleConfigEquals(oldEffective.stars, newEffective.stars); + + if (changed.length === 0 && !starsRelevant) return; const files = await resolveFilesForCohort(this.app, def, { excludeFolderPath: this.plugin.settings.templatesFolderPath, @@ -722,5 +777,77 @@ export default class GlickoSettingsTab extends PluginSettingTab { new Notice(`Wrote "${change.newProp}" on ${res.updated} notes.`); } } + + if (starsRelevant) { + const oldStars = oldEffective.stars; + const newStars = newEffective.stars; + + if (oldStars.enabled && !newStars.enabled && oldStars.property) { + const preview = await previewCohortFrontmatterPropertyUpdates( + this.app, + files, + new Map(), + '', + oldStars.property, + idPropName, + ); + if (preview.wouldUpdate > 0) { + const ok = await new ConfirmModal( + this.app, + 'Remove star rating?', + `Remove frontmatter property "${oldStars.property}" from ${preview.wouldUpdate} notes in this cohort?`, + 'Yes, remove', + "No, don't update", + ).openAndConfirm(); + if (ok) { + const res = await updateCohortFrontmatter( + this.app, + files, + new Map(), + '', + oldStars.property, + `Removing "${oldStars.property}" from ${preview.wouldUpdate} notes...`, + idPropName, + ); + new Notice(`Removed "${oldStars.property}" from ${res.updated} notes.`); + } + } + } else if (newStars.enabled && newStars.property) { + const starVals = cohort ? computeStarsForAll(cohort, newStars) : new Map(); + const oldProp = + oldStars.enabled && oldStars.property && oldStars.property !== newStars.property + ? oldStars.property + : undefined; + const preview = await previewCohortFrontmatterPropertyUpdates( + this.app, + files, + starVals, + newStars.property, + oldProp, + idPropName, + ); + if (preview.wouldUpdate > 0) { + const ok = await new ConfirmModal( + this.app, + 'Update star rating?', + `Write frontmatter property "${newStars.property}" on ${preview.wouldUpdate} notes in this cohort?`, + 'Yes, write', + "No, don't update", + ).openAndConfirm(); + if (ok) { + const res = await updateCohortFrontmatter( + this.app, + files, + starVals, + newStars.property, + oldProp, + `Updating "${newStars.property}" on ${preview.wouldUpdate} notes...`, + idPropName, + ); + new Notice(`Updated ${res.updated} notes.`); + } + } + } + } } } diff --git a/src/settings/frontmatterCopy.ts b/src/settings/frontmatterCopy.ts new file mode 100644 index 0000000..bba35d0 --- /dev/null +++ b/src/settings/frontmatterCopy.ts @@ -0,0 +1,122 @@ +import type { StarMapping, StarScaleConfig } from './settings'; + +// Single source of truth for the names and descriptions of every frontmatter +// property the plugin can write. Both surfaces that render these settings read +// from here so their copy cannot drift: +// - the global settings page (declarative, in SettingsTab.ts) +// - the per-cohort options modal (imperative, via FrontmatterPropertyRow.ts +// and StarScaleRow.ts) + +export type FmSimpleKey = 'rating' | 'uncertainty' | 'rank' | 'matches' | 'wins'; + +export const FM_SIMPLE_KEYS: readonly FmSimpleKey[] = [ + 'rating', + 'uncertainty', + 'rank', + 'matches', + 'wins', +] as const; + +interface SimpleCopy { + // Group heading on the global page; also the single-row name in the cohort modal. + label: string; + // Name of the enable toggle on the global page (where the toggle is its own row). + toggleName: string; + // Description of the enable toggle/combined row + toggleDesc: string; + // Description of the "Property name" row on the global page. + nameDesc: string; +} + +interface FieldCopy { + name: string; + desc: string; +} + +interface StarCopy extends SimpleCopy { + // Extra controls unique to the star scale. + fields: { + max: FieldCopy; + allowZero: FieldCopy; + mode: FieldCopy; + decimals: FieldCopy; + mapping: FieldCopy; + }; +} + +export const PROPERTY_NAME_LABEL = 'Property name'; + +export const FM_SIMPLE_COPY: Record = { + rating: { + label: 'Rating', + toggleName: 'Write rating to frontmatter', + toggleDesc: 'Write the current Glicko rating to a frontmatter property.', + nameDesc: 'Frontmatter property used to store the rating.', + }, + uncertainty: { + label: 'Uncertainty', + toggleName: 'Write uncertainty to frontmatter', + toggleDesc: + 'Write how uncertain the rating is. Starts high and decreases as more comparisons are made.', + nameDesc: 'Frontmatter property used to store the uncertainty.', + }, + rank: { + label: 'Rank', + toggleName: 'Write rank to frontmatter', + toggleDesc: 'Write the cohort rank (1 = highest) to a frontmatter property.', + nameDesc: 'Frontmatter property used to store the rank.', + }, + matches: { + label: 'Matches', + toggleName: 'Write match count to frontmatter', + toggleDesc: 'Write the number of matches played to a frontmatter property.', + nameDesc: 'Frontmatter property used to store the match count.', + }, + wins: { + label: 'Wins', + toggleName: 'Write win count to frontmatter', + toggleDesc: 'Write the number of wins to a frontmatter property.', + nameDesc: 'Frontmatter property used to store the win count.', + }, +}; + +export const FM_STARS_COPY: StarCopy = { + label: 'Star rating', + toggleName: 'Write a star rating to frontmatter', + toggleDesc: + "Write a derived n-star rating (e.g. 1-7) to frontmatter, based on each note's position in the cohort.", + nameDesc: 'Frontmatter property used to store the star rating.', + fields: { + max: { + name: 'Maximum stars', + desc: 'The highest value on the scale (N).', + }, + allowZero: { + name: 'Allow zero', + desc: 'Let the scale start at 0 instead of 1 (e.g. 0-5 rather than 1-5).', + }, + mode: { + name: 'Number type', + desc: 'Write whole numbers, or allow fractional star ratings.', + }, + decimals: { + name: 'Decimal places', + desc: 'Precision used for fractional star ratings.', + }, + mapping: { + name: 'Mapping', + desc: 'By rating spreads notes according to their rating gaps; by rank spreads them evenly across the scale.', + }, + }, +}; + +// Option labels for the star scale dropdowns. +export const STAR_MODE_LABELS: Record = { + integer: 'Whole numbers', + float: 'Decimals', +}; + +export const STAR_MAPPING_LABELS: Record = { + rating: 'By rating (spread by score)', + rank: 'By rank (even spread)', +}; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index cd0c9e1..f1f783d 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -6,12 +6,29 @@ export interface FrontmatterPropertyConfig { enabled: boolean; } +// How a note's star value is derived from the cohort's pairwise results. +// - 'rating': min-max normalise the Glicko rating between the lowest and +// highest rated notes (preserves the size of rating gaps). +// - 'rank': spread notes evenly across the scale by rank position. +export type StarMapping = 'rating' | 'rank'; + +export interface StarScaleConfig { + enabled: boolean; + property: string; + max: number; + allowZero: boolean; + mode: 'integer' | 'float'; + decimals: number; // decimal places used when mode === 'float' + mapping: StarMapping; +} + export interface FrontmatterPropertiesSettings { rating: FrontmatterPropertyConfig; uncertainty: FrontmatterPropertyConfig; rank: FrontmatterPropertyConfig; matches: FrontmatterPropertyConfig; wins: FrontmatterPropertyConfig; + stars: StarScaleConfig; } export interface SessionReportConfig { @@ -47,6 +64,15 @@ export const DEFAULT_SETTINGS: GlickoSettings = { rank: { property: 'glickoRank', enabled: false }, matches: { property: 'glickoMatches', enabled: false }, wins: { property: 'glickoWins', enabled: false }, + stars: { + enabled: false, + property: 'stars', + max: 7, + allowZero: false, + mode: 'integer', + decimals: 1, + mapping: 'rating', + }, }, askForOverridesOnCohortCreation: true, askForReportSettingsOnCreation: true, @@ -74,5 +100,19 @@ export function effectiveFrontmatterProperties( rank: overrides?.rank ?? base.rank, matches: overrides?.matches ?? base.matches, wins: overrides?.wins ?? base.wins, + stars: overrides?.stars ?? base.stars, }; } + +// Used to decide whether a change warrants rewriting cohort frontmatter. +export function starScaleConfigEquals(a: StarScaleConfig, b: StarScaleConfig): boolean { + return ( + a.enabled === b.enabled && + a.property.trim() === b.property.trim() && + a.max === b.max && + a.allowZero === b.allowZero && + a.mode === b.mode && + a.decimals === b.decimals && + a.mapping === b.mapping + ); +} diff --git a/src/storage/PluginDataStore.ts b/src/storage/PluginDataStore.ts index 59222bd..6b1145d 100644 --- a/src/storage/PluginDataStore.ts +++ b/src/storage/PluginDataStore.ts @@ -65,6 +65,11 @@ function mergeSettings(raw?: Partial): GlickoSettings { ...raw.frontmatterProperties, }; + out.frontmatterProperties.stars = { + ...DEFAULT_SETTINGS.frontmatterProperties.stars, + ...raw.frontmatterProperties?.stars, + }; + if (noIdPropertyName) { out.idPropertyName = 'eloId'; } diff --git a/src/ui/CohortOptionsModal.ts b/src/ui/CohortOptionsModal.ts index d7a513a..2d3d442 100644 --- a/src/ui/CohortOptionsModal.ts +++ b/src/ui/CohortOptionsModal.ts @@ -6,15 +6,18 @@ import type { FrontmatterPropertiesSettings, FrontmatterPropertyConfig, SessionReportConfig, + StarScaleConfig, } from '../settings'; -import { DEFAULT_SETTINGS } from '../settings'; +import { DEFAULT_SETTINGS, starScaleConfigEquals } from '../settings'; import type { ScrollStartMode } from '../types'; +import type { FmPropKey } from './FrontmatterPropertyRow'; import { FM_PROP_KEYS, renderStandardFmPropertyRow } from './FrontmatterPropertyRow'; import { BasePromiseModal } from './PromiseModal'; +import { renderStarScaleSettings } from './StarScaleRow'; type Mode = 'create' | 'edit'; -type Key = keyof FrontmatterPropertiesSettings; +type Key = FmPropKey; type RowState = { key: Key; @@ -54,6 +57,7 @@ export class CohortOptionsModal extends BasePromiseModal; + private starWorking: StarScaleConfig; constructor( app: App, @@ -111,6 +115,8 @@ export class CohortOptionsModal extends BasePromiseModal { @@ -131,6 +137,9 @@ export class CohortOptionsModal extends BasePromiseModal + .addExtraButton((b) => b - .setButtonText('Reset') + .setIcon('reset') .setTooltip('Reset to global default') .onClick(() => { this.reportFolderPath = this.plugin.settings.sessionReport.folderPath; @@ -256,9 +265,9 @@ export class CohortOptionsModal extends BasePromiseModal + .addExtraButton((b) => b - .setButtonText('Reset') + .setIcon('reset') .setTooltip('Reset to global default') .onClick(() => { this.reportNameTemplate = this.plugin.settings.sessionReport.nameTemplate; @@ -280,9 +289,9 @@ export class CohortOptionsModal extends BasePromiseModal + .addExtraButton((b) => b - .setButtonText('Reset') + .setIcon('reset') .setTooltip('Reset to global default') .onClick(() => { this.reportTemplatePath = this.plugin.settings.sessionReport.reportTemplatePath ?? ''; @@ -315,6 +324,15 @@ export class CohortOptionsModal extends BasePromiseModal { + this.starWorking = { ...next }; + }, + }); } if (this.mode === 'create') { diff --git a/src/ui/FrontmatterPropertyRow.ts b/src/ui/FrontmatterPropertyRow.ts index 0bbac15..252e084 100644 --- a/src/ui/FrontmatterPropertyRow.ts +++ b/src/ui/FrontmatterPropertyRow.ts @@ -1,39 +1,11 @@ import type { TextComponent, ToggleComponent } from 'obsidian'; import { Setting } from 'obsidian'; -export type FmPropKey = 'rating' | 'uncertainty' | 'rank' | 'matches' | 'wins'; -export const FM_PROP_KEYS: readonly FmPropKey[] = [ - 'rating', - 'uncertainty', - 'rank', - 'matches', - 'wins', -] as const; +import type { FmSimpleKey } from '../settings/frontmatterCopy'; +import { FM_SIMPLE_COPY, FM_SIMPLE_KEYS } from '../settings/frontmatterCopy'; -type Meta = { label: string; desc: string }; - -const META: Record = { - rating: { - label: 'Rating', - desc: 'Write the current Glicko rating to this property.', - }, - uncertainty: { - label: 'Uncertainty', - desc: 'Write how uncertain the rating is. Starts high and decreases as more comparisons are made.', - }, - rank: { - label: 'Rank', - desc: 'Write the cohort rank (1 = highest) to this property.', - }, - matches: { - label: 'Matches', - desc: 'Write the number of matches to this property.', - }, - wins: { - label: 'Wins', - desc: 'Write the number of wins to this property.', - }, -}; +export type FmPropKey = FmSimpleKey; +export const FM_PROP_KEYS = FM_SIMPLE_KEYS; export type FmRowValue = { enabled: boolean; property: string }; export type FmRowRefs = { setting: Setting; text: TextComponent; toggle: ToggleComponent }; @@ -50,7 +22,7 @@ export function renderStandardFmPropertyRow( mode?: RowMode; }, ): FmRowRefs { - const meta = META[key]; + const copy = FM_SIMPLE_COPY[key]; const placeholder = opts.base.property || ''; const mode: RowMode = opts.mode ?? 'cohort'; @@ -63,8 +35,8 @@ export function renderStandardFmPropertyRow( let toggleRef!: ToggleComponent; const setting = new Setting(parent) - .setName(meta.label) - .setDesc(meta.desc) + .setName(copy.label) + .setDesc(copy.toggleDesc) .addToggle((t) => { toggleRef = t; t.setValue(cur.enabled).onChange((v) => { @@ -88,9 +60,9 @@ export function renderStandardFmPropertyRow( }); if (mode === 'cohort') { - setting.addButton((b) => + setting.addExtraButton((b) => b - .setButtonText('Reset') + .setIcon('reset') .setTooltip('Reset to global default') .onClick(() => { cur.enabled = !!opts.base.enabled; diff --git a/src/ui/StarScaleRow.ts b/src/ui/StarScaleRow.ts new file mode 100644 index 0000000..ac34f2a --- /dev/null +++ b/src/ui/StarScaleRow.ts @@ -0,0 +1,167 @@ +import { Setting } from 'obsidian'; + +import type { StarScaleConfig } from '../settings'; +import { DEFAULT_SETTINGS } from '../settings'; +import { + FM_STARS_COPY, + PROPERTY_NAME_LABEL, + STAR_MAPPING_LABELS, + STAR_MODE_LABELS, +} from '../settings/frontmatterCopy'; + +type RowMode = 'global' | 'cohort'; + +// Imperatively render the Star rating configuration for the per-cohort options modal. +// The global settings page renders the equivalent controls declaratively. +export function renderStarScaleSettings( + parent: HTMLElement, + opts: { + value: StarScaleConfig; + base: StarScaleConfig; + mode?: RowMode; + onChange: (next: StarScaleConfig) => void | Promise; + }, +): void { + const mode: RowMode = opts.mode ?? 'cohort'; + const cur: StarScaleConfig = { ...opts.value }; + const emit = () => void opts.onChange({ ...cur }); + + const defaultProp = opts.base.property || DEFAULT_SETTINGS.frontmatterProperties.stars.property; + + const dependents: Setting[] = []; + + const enableSetting = new Setting(parent) + .setName(FM_STARS_COPY.label) + .setDesc(FM_STARS_COPY.toggleDesc) + .addToggle((t) => + t.setValue(cur.enabled).onChange((v) => { + cur.enabled = !!v; + refreshControls(); + emit(); + }), + ); + + if (mode === 'cohort') { + enableSetting.addExtraButton((b) => + b + .setIcon('reset') + .setTooltip('Reset to global default') + .onClick(() => { + Object.assign(cur, opts.base); + rerender(); + emit(); + }), + ); + } + + const propSetting = new Setting(parent) + .setName(PROPERTY_NAME_LABEL) + .setDesc(FM_STARS_COPY.nameDesc) + .addText((t) => + t + .setPlaceholder(defaultProp) + .setValue(cur.property) + .onChange((v) => { + const trimmed = (v ?? '').trim(); + cur.property = trimmed.length > 0 ? trimmed : defaultProp; + emit(); + }), + ); + dependents.push(propSetting); + + const maxSetting = new Setting(parent) + .setName(FM_STARS_COPY.fields.max.name) + .setDesc(FM_STARS_COPY.fields.max.desc) + .addText((t) => { + t.inputEl.type = 'number'; + t.inputEl.min = '2'; + t.setValue(String(cur.max)).onChange((v) => { + const n = Math.round(Number(v)); + if (Number.isFinite(n) && n >= 2) { + cur.max = n; + emit(); + } + }); + }); + dependents.push(maxSetting); + + const allowZeroSetting = new Setting(parent) + .setName(FM_STARS_COPY.fields.allowZero.name) + .setDesc(FM_STARS_COPY.fields.allowZero.desc) + .addToggle((t) => + t.setValue(cur.allowZero).onChange((v) => { + cur.allowZero = !!v; + emit(); + }), + ); + dependents.push(allowZeroSetting); + + const modeSetting = new Setting(parent) + .setName(FM_STARS_COPY.fields.mode.name) + .setDesc(FM_STARS_COPY.fields.mode.desc) + .addDropdown((dd) => + dd + .addOptions(STAR_MODE_LABELS) + .setValue(cur.mode) + .onChange((v) => { + cur.mode = v === 'float' ? 'float' : 'integer'; + refreshDecimalsVisibility(); + emit(); + }), + ); + dependents.push(modeSetting); + + const decimalsSetting = new Setting(parent) + .setName(FM_STARS_COPY.fields.decimals.name) + .setDesc(FM_STARS_COPY.fields.decimals.desc) + .addText((t) => { + t.inputEl.type = 'number'; + t.inputEl.min = '1'; + t.inputEl.max = '2'; + t.setValue(String(cur.decimals === 2 ? 2 : 1)).onChange((v) => { + const n = Math.round(Number(v)); + if (n === 1 || n === 2) { + cur.decimals = n; + emit(); + } + }); + }); + dependents.push(decimalsSetting); + + const mappingSetting = new Setting(parent) + .setName(FM_STARS_COPY.fields.mapping.name) + .setDesc(FM_STARS_COPY.fields.mapping.desc) + .addDropdown((dd) => + dd + .addOptions(STAR_MAPPING_LABELS) + .setValue(cur.mapping) + .onChange((v) => { + cur.mapping = v === 'rank' ? 'rank' : 'rating'; + emit(); + }), + ); + dependents.push(mappingSetting); + + refreshControls(); + + function refreshControls() { + for (const s of dependents) { + if (mode === 'cohort') { + s.settingEl.toggle(cur.enabled); + } else { + s.setDisabled(!cur.enabled); + } + } + refreshDecimalsVisibility(); + } + + function refreshDecimalsVisibility() { + decimalsSetting.settingEl.toggle(cur.enabled && cur.mode === 'float'); + } + + function rerender() { + enableSetting.settingEl.remove(); + for (const s of dependents) s.settingEl.remove(); + renderStarScaleSettings(parent, { ...opts, value: { ...cur } }); + } +} diff --git a/src/utils/FrontmatterStats.ts b/src/utils/FrontmatterStats.ts index a46713d..3e2d88d 100644 --- a/src/utils/FrontmatterStats.ts +++ b/src/utils/FrontmatterStats.ts @@ -1,6 +1,6 @@ import type { App, TFile } from 'obsidian'; -import type { FrontmatterPropertiesSettings } from '../settings'; +import type { FrontmatterPropertiesSettings, StarScaleConfig } from '../settings'; import type { CohortData } from '../types'; import { getNoteId } from './NoteIds'; import { withNotice } from './safe'; @@ -11,6 +11,7 @@ type PlayerStats = { matches: number; wins: number; rank: number; + stars?: number; }; function anyEnabled(fm: FrontmatterPropertiesSettings): boolean { @@ -19,10 +20,79 @@ function anyEnabled(fm: FrontmatterPropertiesSettings): boolean { !!fm.uncertainty.enabled || !!fm.rank.enabled || !!fm.matches.enabled || - !!fm.wins.enabled + !!fm.wins.enabled || + !!fm.stars.enabled ); } +// Compute a derived n-star value for every rated note in the cohort +export function computeStarsForAll(cohort: CohortData, cfg: StarScaleConfig): Map { + const map = new Map(); + const rated = Object.entries(cohort.players) + .filter(([, p]) => p.matches >= 1) + .map(([id, p]) => ({ id, rating: p.rating })); + if (rated.length === 0) return map; + + const floor = cfg.allowZero ? 0 : 1; + const top = cfg.max; + const range = top - floor; + + const quantize = (value: number): number => { + const clamped = Math.min(top, Math.max(floor, value)); + if (cfg.mode === 'integer') return Math.round(clamped); + const places = Math.max(0, Math.min(6, Math.floor(cfg.decimals ?? 1))); + const factor = Math.pow(10, places); + return Math.round(clamped * factor) / factor; + }; + + // Degenerate scale: nothing meaningful to spread across + if (range <= 0) { + for (const { id } of rated) map.set(id, quantize(floor)); + return map; + } + + const midpoint = floor + range / 2; + + if (cfg.mapping === 'rank') { + // Even spread by rank position (1 = best). Standard competition ranking, + // so tied ratings share the higher rank. + const sorted = [...rated].sort((a, b) => b.rating - a.rating); + const m = sorted.length; + if (m === 1) { + map.set(sorted[0].id, quantize(midpoint)); + return map; + } + let lastRating: number | undefined; + let rank = 0; + for (let i = 0; i < m; i++) { + const { id, rating } = sorted[i]; + if (lastRating === undefined || rating !== lastRating) { + rank = i + 1; + lastRating = rating; + } + const t = (m - rank) / (m - 1); // best -> 1, worst -> 0 + map.set(id, quantize(floor + t * range)); + } + return map; + } + + let min = Infinity; + let max = -Infinity; + for (const { rating } of rated) { + if (rating < min) min = rating; + if (rating > max) max = rating; + } + if (max === min) { + for (const { id } of rated) map.set(id, quantize(midpoint)); + return map; + } + for (const { id, rating } of rated) { + const t = (rating - min) / (max - min); + map.set(id, quantize(floor + t * range)); + } + return map; +} + // Standard competition ranking ("1224" style) export function computeRanksForAll(cohort: CohortData): Map { const entries = Object.entries(cohort.players); @@ -95,6 +165,9 @@ function buildProps(fm: FrontmatterPropertiesSettings, stats: PlayerStats): Reco if (fm.wins.enabled && fm.wins.property) { out[fm.wins.property] = stats.wins; } + if (fm.stars.enabled && fm.stars.property && typeof stats.stars === 'number') { + out[fm.stars.property] = stats.stars; + } return out; } @@ -122,13 +195,17 @@ export async function writeFrontmatterStatsForPair( const ids = [aId, bId].filter((id): id is string => !!id); const rankMap = computeRanksForSubset(cohort, ids); + // Stars depend on the cohort-wide min/max (or full ranking), so compute the + // whole map and look up the pair. Other notes are refreshed at session end. + const starMap = + fm.stars.enabled && fm.stars.property ? computeStarsForAll(cohort, fm.stars) : undefined; const tasks: Promise[] = []; if (aFile && aId) { - tasks.push(writeFrontmatterStatsForPlayer(app, fm, cohort, aFile, aId, rankMap)); + tasks.push(writeFrontmatterStatsForPlayer(app, fm, cohort, aFile, aId, rankMap, starMap)); } if (bFile && bId) { - tasks.push(writeFrontmatterStatsForPlayer(app, fm, cohort, bFile, bId, rankMap)); + tasks.push(writeFrontmatterStatsForPlayer(app, fm, cohort, bFile, bId, rankMap, starMap)); } await Promise.all(tasks); @@ -291,15 +368,41 @@ export async function updateCohortFrontmatter( ); } -export async function updateCohortRanksInFrontmatter( +// Refresh the rank and star properties across an entire cohort. +export async function refreshCohortRankAndStars( app: App, - cohort: CohortData | undefined, + fm: FrontmatterPropertiesSettings, + cohort: CohortData, files: TFile[], - newPropName: string, -): Promise<{ updated: number }> { - if (!cohort) return { updated: 0 }; - const rankMap = computeRanksForAll(cohort); - return updateCohortFrontmatterProperties(app, files, rankMap, newPropName); + idPropertyName?: string, + rankMap?: Map, +): Promise { + const wantRank = !!fm.rank.enabled && !!fm.rank.property; + const wantStars = !!fm.stars.enabled && !!fm.stars.property; + if ((!wantRank && !wantStars) || files.length === 0) return; + + if (wantRank) { + await updateCohortFrontmatter( + app, + files, + rankMap ?? computeRanksForAll(cohort), + fm.rank.property, + undefined, + 'Updating ranks...', + idPropertyName, + ); + } + if (wantStars) { + await updateCohortFrontmatter( + app, + files, + computeStarsForAll(cohort, fm.stars), + fm.stars.property, + undefined, + 'Updating star ratings...', + idPropertyName, + ); + } } export async function writeFrontmatterStatsForPlayer( @@ -309,6 +412,7 @@ export async function writeFrontmatterStatsForPlayer( file: TFile, playerId: string, precomputedRankMap: Map, + starMap?: Map, ): Promise { if (!anyEnabled(fm)) return; const p = cohort.players[playerId]; @@ -320,6 +424,7 @@ export async function writeFrontmatterStatsForPlayer( matches: p.matches, wins: p.wins, rank: rankMap.get(playerId) ?? rankMap.size, + stars: starMap?.get(playerId), }); await writeProps(app, file, props); }