Add star rating frontmatter property

This commit is contained in:
Jack Williams 2026-06-08 16:24:22 +10:00
parent d2ba407281
commit 92517fd9ba
11 changed files with 686 additions and 150 deletions

View file

@ -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.

View file

@ -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);
}
}

View file

@ -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);
},
);
}
}

View file

@ -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<string, unknown>, 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<string, number>();
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.`);
}
}
}
}
}
}

View file

@ -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<FmSimpleKey, SimpleCopy> = {
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<StarScaleConfig['mode'], string> = {
integer: 'Whole numbers',
float: 'Decimals',
};
export const STAR_MAPPING_LABELS: Record<StarMapping, string> = {
rating: 'By rating (spread by score)',
rank: 'By rank (even spread)',
};

View file

@ -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
);
}

View file

@ -65,6 +65,11 @@ function mergeSettings(raw?: Partial<GlickoSettings>): GlickoSettings {
...raw.frontmatterProperties,
};
out.frontmatterProperties.stars = {
...DEFAULT_SETTINGS.frontmatterProperties.stars,
...raw.frontmatterProperties?.stars,
};
if (noIdPropertyName) {
out.idPropertyName = 'eloId';
}

View file

@ -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<CohortOptionsResult | u
private reportTemplatePath = '';
private working: Record<Key, RowState>;
private starWorking: StarScaleConfig;
constructor(
app: App,
@ -111,6 +115,8 @@ export class CohortOptionsModal extends BasePromiseModal<CohortOptionsResult | u
matches: mk('matches'),
wins: mk('wins'),
};
this.starWorking = { ...(this.initial?.stars ?? this.base.stars) };
}
async openAndGetOptions(): Promise<CohortOptionsResult | undefined> {
@ -131,6 +137,9 @@ export class CohortOptionsModal extends BasePromiseModal<CohortOptionsResult | u
out[key] = { property: row.property.trim(), enabled: !!row.enabled };
}
}
if (!starScaleConfigEquals(this.starWorking, this.base.stars)) {
out.stars = { ...this.starWorking, property: this.starWorking.property.trim() };
}
return out;
}
@ -141,7 +150,7 @@ export class CohortOptionsModal extends BasePromiseModal<CohortOptionsResult | u
const desc =
this.mode === 'create'
? 'Set an optional name and configure which Glicko statistics to write into frontmatter for this cohort. Global defaults are prefilled.'
: 'Rename the cohort and adjust which Glicko statistics to write into frontmatter. Use Reset to revert a property to the global default.';
: 'Rename the cohort and adjust which Glicko statistics to write into frontmatter. Use the reset button to revert a property to the global default.';
contentEl.createEl('h3', { text: 'Cohort options' });
contentEl.createEl('p', { text: desc });
@ -234,9 +243,9 @@ export class CohortOptionsModal extends BasePromiseModal<CohortOptionsResult | u
this.reportFolderPath = (v ?? '').trim();
});
})
.addButton((b) =>
.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<CohortOptionsResult | u
this.reportNameTemplate = (v ?? '').trim();
});
})
.addButton((b) =>
.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<CohortOptionsResult | u
this.reportTemplatePath = (v ?? '').trim();
});
})
.addButton((b) =>
.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<CohortOptionsResult | u
},
});
}
renderStarScaleSettings(contentEl, {
value: this.starWorking,
base: this.base.stars,
mode: 'cohort',
onChange: (next) => {
this.starWorking = { ...next };
},
});
}
if (this.mode === 'create') {

View file

@ -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<FmPropKey, Meta> = {
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;

167
src/ui/StarScaleRow.ts Normal file
View file

@ -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>;
},
): 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 } });
}
}

View file

@ -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<string, number> {
const map = new Map<string, number>();
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<string, number> {
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<void>[] = [];
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<string, number>,
): Promise<void> {
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<string, number>,
starMap?: Map<string, number>,
): Promise<void> {
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);
}