mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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:
@@ -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
66
docs/TEMPLATE_RANKING.md
Normal 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.
|
||||
|
||||
---
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -1095,7 +1095,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
id: 'Comfy.Templates.SortBy',
|
||||
name: 'Template library - Sort preference',
|
||||
type: 'hidden',
|
||||
defaultValue: 'newest'
|
||||
defaultValue: 'default'
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -197,6 +197,8 @@ export interface TemplateFilterMetadata {
|
||||
selected_runs_on: string[]
|
||||
sort_by:
|
||||
| 'default'
|
||||
| 'recommended'
|
||||
| 'popular'
|
||||
| 'alphabetical'
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
66
src/stores/templateRankingStore.ts
Normal file
66
src/stores/templateRankingStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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, '-')}`
|
||||
}
|
||||
135
tests-ui/stores/templateRankingStore.test.ts
Normal file
135
tests-ui/stores/templateRankingStore.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user