feat: New Template Library (#7062)

## Summary

Implement the new design for template library

## Changes

- What
  - New sort option: `Popular` and  `Recommended`
  - New category: `Popular`, leverage the `Popular` sorting
  - Support add category stick to top of the side bar 
- Support template customized visible in different platform by
`includeOnDistributions` field

### How to make `Popular` and `Recommended` work

Add usage-based ordering to workflow templates with position bias
correction, manual ranking (searchRank), and freshness boost.

New sort modes:
- "Recommended" (default): usage × 0.5 + searchRank × 0.3 + freshness ×
0.2
- "Popular": usage × 0.9 + freshness × 0.1

## Screenshots (if applicable)

New default ordering:

<img width="1812" height="1852" alt="Selection_2485"
src="https://github.com/user-attachments/assets/8f4ed6e9-9cf4-43a8-8796-022dcf4c277e"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7062-feat-usage-based-template-ordering-2bb6d73d365081f1ac65f8ad55fe8ce6)
by [Unito](https://www.unito.io)

Popular category:

<img width="281" height="283" alt="image"
src="https://github.com/user-attachments/assets/fd54fcb8-6caa-4982-a6b6-1f70ca4b31e3"
/>

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Christian Byrne
2026-01-06 10:10:40 -08:00
committed by GitHub
parent a7d0825a14
commit fbdaf5d7f3
14 changed files with 476 additions and 26 deletions

View File

@@ -83,7 +83,7 @@ test.describe('Templates', () => {
await comfyPage.page
.locator(
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
)
.click()
await comfyPage.templates.loadTemplate('default')

66
docs/TEMPLATE_RANKING.md Normal file
View File

@@ -0,0 +1,66 @@
# Template Ranking System
Usage-based ordering for workflow templates with position bias normalization.
Scores are pre-computed and normalized offline and shipped as static JSON (mirrors `sorted-custom-node-map.json` pattern for node search).
## Sort Modes
| Mode | Formula | Description |
| -------------- | ------------------------------------------------ | ---------------------- |
| `recommended` | `usage × 0.5 + internal × 0.3 + freshness × 0.2` | Curated recommendation |
| `popular` | `usage × 0.9 + freshness × 0.1` | Pure user-driven |
| `newest` | Date sort | Existing |
| `alphabetical` | Name sort | Existing |
Freshness computed at runtime from `template.date`: `1.0 / (1 + daysSinceAdded / 90)`, min 0.1.
## Data Files
**Usage scores** (generated from Mixpanel):
```json
// In templates/index.json, add to any template:
{
"name": "some_template",
"usage": 1000,
...
}
```
**Search rank** (set per-template in workflow_templates repo):
```json
// In templates/index.json, add to any template:
{
"name": "some_template",
"searchRank": 8, // Scale 1-10, default 5
...
}
```
| searchRank | Effect |
| ---------- | ---------------------------- |
| 1-4 | Demote (bury in results) |
| 5 | Neutral (default if not set) |
| 6-10 | Promote (boost in results) |
## Position Bias Correction
Raw usage reflects true preference AND UI position bias. We use linear interpolation:
```
correction = 1 + (position - 1) / (maxPosition - 1)
normalizedUsage = rawUsage × correction
```
| Position | Boost |
| -------- | ----- |
| 1 | 1.0× |
| 50 | 1.28× |
| 100 | 1.57× |
| 175 | 2.0× |
Templates buried at the bottom get up to 2× boost to compensate for reduced visibility.
---

View File

@@ -175,6 +175,7 @@
<!-- Actual Template Cards -->
<CardContainer
v-for="template in isLoading ? [] : displayTemplates"
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="compact"
@@ -405,6 +406,8 @@ import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
@@ -423,6 +426,30 @@ onMounted(() => {
sessionStartTime.value = Date.now()
})
const systemStatsStore = useSystemStatsStore()
const distributions = computed(() => {
// eslint-disable-next-line no-undef
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]
case 'localhost':
return [TemplateIncludeOnDistributionEnum.Local]
case 'desktop':
default:
if (systemStatsStore.systemStats?.system.os === 'darwin') {
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Mac
]
}
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Windows
]
}
})
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
@@ -511,6 +538,9 @@ const allTemplates = computed(() => {
return workflowTemplatesStore.enhancedTemplates
})
// Navigation
const selectedNavItem = ref<string | null>('all')
// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {
if (!selectedNavItem.value) {
@@ -536,6 +566,36 @@ const {
resetFilters
} = useTemplateFiltering(navigationFilteredTemplates)
/**
* Coordinates state between the selected navigation item and the sort order to
* create deterministic, predictable behavior.
* @param source The origin of the change ('nav' or 'sort').
*/
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
const isPopularNav = selectedNavItem.value === 'popular'
const isPopularSort = sortBy.value === 'popular'
if (source === 'nav') {
if (isPopularNav && !isPopularSort) {
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
sortBy.value = 'popular'
} else if (!isPopularNav && isPopularSort) {
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
sortBy.value = 'default'
}
} else if (source === 'sort') {
// When sort is changed away from 'Popular' while in the 'Popular' category,
// reset the category to 'All Templates' to avoid a confusing state.
if (isPopularNav && !isPopularSort) {
selectedNavItem.value = 'all'
}
}
}
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
watch(sortBy, () => coordinateNavAndSort('sort'))
// Convert between string array and object array for MultiSelect component
const selectedModelObjects = computed({
get() {
@@ -578,9 +638,6 @@ const cardRefs = ref<HTMLElement[]>([])
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)
// Navigation
const selectedNavItem = ref<string | null>('all')
// Search text for model filter
const modelSearchText = ref<string>('')
@@ -645,11 +702,19 @@ const runsOnFilterLabel = computed(() => {
// Sort options
const sortOptions = computed(() => [
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.default', 'Default'),
value: 'default'
},
{
name: t('templateWorkflows.sort.recommended', 'Recommended'),
value: 'recommended'
},
{
name: t('templateWorkflows.sort.popular', 'Popular'),
value: 'popular'
},
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
value: 'vram-low-to-high'
@@ -750,7 +815,7 @@ const pageTitle = computed(() => {
// Initialize templates loading with useAsyncState
const { isLoading } = useAsyncState(
async () => {
// Run both operations in parallel for better performance
// Run all operations in parallel for better performance
await Promise.all([
loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates()
@@ -763,6 +828,14 @@ const { isLoading } = useAsyncState(
}
)
const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
return (template.includeOnDistributions?.length ?? 0) > 0
? distributions.value.some((d) =>
template.includeOnDistributions?.includes(d)
)
: true
}
onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})

View File

@@ -1,3 +1,4 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
@@ -19,10 +20,22 @@ const defaultSettingStore = {
set: vi.fn().mockResolvedValue(undefined)
}
const defaultRankingStore = {
computeDefaultScore: vi.fn(() => 0),
computePopularScore: vi.fn(() => 0),
getUsageScore: vi.fn(() => 0),
computeFreshness: vi.fn(() => 0.5),
isLoaded: { value: false }
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => defaultSettingStore)
}))
vi.mock('@/stores/templateRankingStore', () => ({
useTemplateRankingStore: vi.fn(() => defaultRankingStore)
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackTemplateFilterChanged: vi.fn()
@@ -34,6 +47,7 @@ const { useTemplateFiltering } =
describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})

View File

@@ -6,12 +6,14 @@ import type { Ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
import { debounce } from 'es-toolkit/compat'
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
) {
const settingStore = useSettingStore()
const rankingStore = useTemplateRankingStore()
const searchQuery = ref('')
const selectedModels = ref<string[]>(
@@ -25,6 +27,8 @@ export function useTemplateFiltering(
)
const sortBy = ref<
| 'default'
| 'recommended'
| 'popular'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'
@@ -151,10 +155,42 @@ export function useTemplateFiltering(
return Number.POSITIVE_INFINITY
}
watch(
filteredByRunsOn,
(templates) => {
rankingStore.largestUsageScore = Math.max(
...templates.map((t) => t.usage || 0)
)
},
{ immediate: true }
)
const sortedTemplates = computed(() => {
const templates = [...filteredByRunsOn.value]
switch (sortBy.value) {
case 'recommended':
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
return templates.sort((a, b) => {
const scoreA = rankingStore.computeDefaultScore(
a.date,
a.searchRank,
a.usage
)
const scoreB = rankingStore.computeDefaultScore(
b.date,
b.searchRank,
b.usage
)
return scoreB - scoreA
})
case 'popular':
// User-driven: usage × 0.9 + freshness × 0.1
return templates.sort((a, b) => {
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
return scoreB - scoreA
})
case 'alphabetical':
return templates.sort((a, b) => {
const nameA = a.title || a.name || ''
@@ -184,7 +220,7 @@ export function useTemplateFiltering(
return vramA - vramB
})
case 'model-size-low-to-high':
return templates.sort((a: any, b: any) => {
return templates.sort((a, b) => {
const sizeA =
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
const sizeB =
@@ -194,7 +230,6 @@ export function useTemplateFiltering(
})
case 'default':
default:
// Keep original order (default order)
return templates
}
})
@@ -206,7 +241,7 @@ export function useTemplateFiltering(
selectedModels.value = []
selectedUseCases.value = []
selectedRunsOn.value = []
sortBy.value = 'newest'
sortBy.value = 'default'
}
const removeModelFilter = (model: string) => {

View File

@@ -873,7 +873,7 @@
"noResultsHint": "Try adjusting your search or filters",
"allTemplates": "All Templates",
"modelFilter": "Model Filter",
"useCaseFilter": "Use Case",
"useCaseFilter": "Tasks",
"licenseFilter": "License",
"modelsSelected": "{count} Models",
"useCasesSelected": "{count} Use Cases",
@@ -882,6 +882,7 @@
"resultsCount": "Showing {count} of {total} templates",
"sort": {
"recommended": "Recommended",
"popular": "Popular",
"alphabetical": "A → Z",
"newest": "Newest",
"searchPlaceholder": "Search...",

View File

@@ -1095,7 +1095,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Templates.SortBy',
name: 'Template library - Sort preference',
type: 'hidden',
defaultValue: 'newest'
defaultValue: 'default'
},
/**

View File

@@ -197,6 +197,8 @@ export interface TemplateFilterMetadata {
selected_runs_on: string[]
sort_by:
| 'default'
| 'recommended'
| 'popular'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'

View File

@@ -6,7 +6,7 @@ import { i18n, st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { getCategoryIcon } from '@/utils/categoryIcons'
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import type {
@@ -276,9 +276,18 @@ export const useWorkflowTemplatesStore = defineStore(
return enhancedTemplates.value
}
if (categoryId === 'basics') {
if (categoryId.startsWith('basics-')) {
// Filter for templates from categories marked as essential
return enhancedTemplates.value.filter((t) => t.isEssential)
return enhancedTemplates.value.filter(
(t) =>
t.isEssential &&
t.category?.toLowerCase().replace(/\s+/g, '-') ===
categoryId.replace('basics-', '')
)
}
if (categoryId === 'popular') {
return enhancedTemplates.value
}
if (categoryId === 'partner-nodes') {
@@ -333,20 +342,34 @@ export const useWorkflowTemplatesStore = defineStore(
icon: getCategoryIcon('all')
})
// 2. Basics (isEssential categories) - always second if it exists
const essentialCat = coreTemplates.value.find(
// 1.5. Popular categories
items.push({
id: 'popular',
label: st('templateWorkflows.category.Popular', 'Popular'),
icon: 'icon-[lucide--flame]'
})
// 2. Basics (isEssential categories) - always beneath All Templates if they exist
const essentialCats = coreTemplates.value.filter(
(cat) => cat.isEssential && cat.templates.length > 0
)
if (essentialCat) {
if (essentialCats.length > 0) {
essentialCats.forEach((essentialCat) => {
const categoryIcon = essentialCat.icon
const categoryTitle = essentialCat.title ?? 'Getting Started'
const categoryId = generateCategoryId('basics', essentialCat.title)
items.push({
id: 'basics',
id: categoryId,
label: st(
`templateWorkflows.category.${normalizeI18nKey(categoryTitle)}`,
categoryTitle
),
icon: 'icon-[lucide--graduation-cap]'
icon:
categoryIcon ||
getCategoryIcon(essentialCat.type || 'getting-started')
})
})
}
@@ -375,7 +398,7 @@ export const useWorkflowTemplatesStore = defineStore(
const group = categoryGroups.get(categoryGroup)!
// Generate unique ID for this category
const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}`
const categoryId = generateCategoryId(categoryGroup, category.title)
// Store the filter mapping
categoryFilters.value.set(categoryId, {

View File

@@ -32,6 +32,29 @@ export interface TemplateInfo {
* Templates with this field will be hidden on local installations temporarily.
*/
requiresCustomNodes?: string[]
/**
* Manual ranking boost/demotion for "Recommended" sort. Scale 1-10, default 5.
* Higher values promote the template, lower values demote it.
*/
searchRank?: number
/**
* Usage score based on real world usage statistics.
* Used for popular templates sort and for "Recommended" sort boost.
*/
usage?: number
/**
* Manage template's visibility across different distributions by specifying which distributions it should be included on.
* If not specified, the template will be included on all distributions.
*/
includeOnDistributions?: TemplateIncludeOnDistributionEnum[]
}
export enum TemplateIncludeOnDistributionEnum {
Cloud = 'cloud',
Local = 'local',
Desktop = 'desktop',
Mac = 'mac',
Windows = 'windows'
}
export interface WorkflowTemplates {

View File

@@ -526,6 +526,8 @@ const zSettings = z.object({
'Comfy.Templates.SelectedRunsOn': z.array(z.string()),
'Comfy.Templates.SortBy': z.enum([
'default',
'recommended',
'popular',
'alphabetical',
'newest',
'vram-low-to-high',

View File

@@ -0,0 +1,66 @@
/**
* Store for template ranking scores.
* Loads pre-computed usage scores from static JSON.
* Internal ranks come from template.searchRank in index.json.
* See docs/TEMPLATE_RANKING.md for details.
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useTemplateRankingStore = defineStore('templateRanking', () => {
const largestUsageScore = ref<number>()
const normalizeUsageScore = (usage: number): number => {
return usage / (largestUsageScore.value ?? usage)
}
/**
* Compute freshness score based on template date.
* Returns 1.0 for brand new, decays to 0.1 over ~6 months.
*/
const computeFreshness = (dateStr: string | undefined): number => {
if (!dateStr) return 0.5 // Default for templates without dates
const date = new Date(dateStr)
if (isNaN(date.getTime())) return 0.5
const daysSinceAdded = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)
return Math.max(0.1, 1.0 / (1 + daysSinceAdded / 90))
}
/**
* Compute composite score for "default" sort.
* Formula: usage × 0.5 + internal × 0.3 + freshness × 0.2
*/
const computeDefaultScore = (
dateStr: string | undefined,
searchRank: number | undefined,
usage: number = 0
): number => {
const internal = (searchRank ?? 5) / 10 // Normalize 1-10 to 0-1
const freshness = computeFreshness(dateStr)
return normalizeUsageScore(usage) * 0.5 + internal * 0.3 + freshness * 0.2
}
/**
* Compute composite score for "popular" sort.
* Formula: usage × 0.9 + freshness × 0.1
*/
const computePopularScore = (
dateStr: string | undefined,
usage: number = 0
): number => {
const freshness = computeFreshness(dateStr)
return normalizeUsageScore(usage) * 0.9 + freshness * 0.1
}
return {
largestUsageScore,
computeFreshness,
computeDefaultScore,
computePopularScore
}
})

View File

@@ -50,3 +50,13 @@ export const getCategoryIcon = (categoryId: string): string => {
// Return mapped icon or fallback to folder
return iconMap[categoryId.toLowerCase()] || 'icon-[lucide--folder]'
}
/**
* Generates a unique category ID from a category group and title
*/
export function generateCategoryId(
categoryGroup: string,
categoryTitle: string
) {
return `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${categoryTitle.toLowerCase().replace(/\s+/g, '-')}`
}

View File

@@ -0,0 +1,135 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
// Mock axios
vi.mock('axios', () => ({
default: {
get: vi.fn()
}
}))
describe('templateRankingStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('computeFreshness', () => {
it('returns 1.0 for brand new template (today)', () => {
const store = useTemplateRankingStore()
const today = new Date().toISOString().split('T')[0]
const freshness = store.computeFreshness(today)
expect(freshness).toBeCloseTo(1.0, 1)
})
it('returns ~0.5 for 90-day old template', () => {
const store = useTemplateRankingStore()
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0]
const freshness = store.computeFreshness(ninetyDaysAgo)
expect(freshness).toBeCloseTo(0.5, 1)
})
it('returns 0.1 minimum for very old template', () => {
const store = useTemplateRankingStore()
const freshness = store.computeFreshness('2020-01-01')
expect(freshness).toBe(0.1)
})
it('returns 0.5 for undefined date', () => {
const store = useTemplateRankingStore()
expect(store.computeFreshness(undefined)).toBe(0.5)
})
it('returns 0.5 for invalid date', () => {
const store = useTemplateRankingStore()
expect(store.computeFreshness('not-a-date')).toBe(0.5)
})
})
describe('computeDefaultScore', () => {
it('uses default searchRank of 5 when not provided', () => {
const store = useTemplateRankingStore()
// Set largestUsageScore to avoid NaN when usage is 0
store.largestUsageScore = 100
const score = store.computeDefaultScore('2024-01-01', undefined, 0)
// With no usage score loaded, usage = 0
// internal = 5/10 = 0.5, freshness ~0.1 (old date)
// score = 0 * 0.5 + 0.5 * 0.3 + 0.1 * 0.2 = 0.15 + 0.02 = 0.17
expect(score).toBeCloseTo(0.17, 1)
})
it('high searchRank (10) boosts score', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100
const lowRank = store.computeDefaultScore('2024-01-01', 1, 0)
const highRank = store.computeDefaultScore('2024-01-01', 10, 0)
expect(highRank).toBeGreaterThan(lowRank)
})
it('low searchRank (1) demotes score', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100
const neutral = store.computeDefaultScore('2024-01-01', 5, 0)
const demoted = store.computeDefaultScore('2024-01-01', 1, 0)
expect(demoted).toBeLessThan(neutral)
})
it('searchRank difference is significant', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100
const rank1 = store.computeDefaultScore('2024-01-01', 1, 0)
const rank10 = store.computeDefaultScore('2024-01-01', 10, 0)
// Difference should be 0.9 * 0.3 = 0.27 (30% weight, 0.9 range)
expect(rank10 - rank1).toBeCloseTo(0.27, 2)
})
})
describe('computePopularScore', () => {
it('does not use searchRank', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100
// Popular score ignores searchRank - just usage + freshness
const score1 = store.computePopularScore('2024-01-01', 0)
const score2 = store.computePopularScore('2024-01-01', 0)
expect(score1).toBe(score2)
})
it('newer templates score higher', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100
const today = new Date().toISOString().split('T')[0]
const oldScore = store.computePopularScore('2020-01-01', 0)
const newScore = store.computePopularScore(today, 0)
expect(newScore).toBeGreaterThan(oldScore)
})
})
describe('searchRank edge cases', () => {
it('handles searchRank of 0 (should still work, treated as very low)', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100
const score = store.computeDefaultScore('2024-01-01', 0, 0)
expect(score).toBeGreaterThanOrEqual(0)
})
it('handles searchRank above 10 (clamping not enforced, but works)', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100
const rank10 = store.computeDefaultScore('2024-01-01', 10, 0)
const rank15 = store.computeDefaultScore('2024-01-01', 15, 0)
expect(rank15).toBeGreaterThan(rank10)
})
it('handles negative searchRank', () => {
const store = useTemplateRankingStore()
store.largestUsageScore = 100
const score = store.computeDefaultScore('2024-01-01', -5, 0)
// Should still compute, just negative contribution from searchRank
expect(typeof score).toBe('number')
})
})
})