From 15f9adb4de2f2fdeeebd2be2d34a9e0a1aaa1c3c Mon Sep 17 00:00:00 2001 From: mathieudutour Date: Mon, 19 Oct 2026 17:36:59 +0200 Subject: [PATCH] Fix the frecency algorithm ignoring the age of past visits --- docs/utils-reference/getting-started.md | 4 + package-lock.json | 4 +- package.json | 2 +- smoke-tests/package-lock.json | 2 +- src/useFrecencySorting.ts | 71 ++++---- test/useFrecencySorting.test.tsx | 221 ++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 33 deletions(-) create mode 100644 test/useFrecencySorting.test.tsx diff --git a/docs/utils-reference/getting-started.md b/docs/utils-reference/getting-started.md index 32d8a04..130784c 100644 --- a/docs/utils-reference/getting-started.md +++ b/docs/utils-reference/getting-started.md @@ -16,6 +16,10 @@ npm install --save @raycast/utils ## Changelog +### v2.3.3 + +- Fixed `useFrecencySorting` ignoring the age of past visits when sorting. Existing rankings are migrated automatically without resetting usage history. + ### v2.3.2 - Allow overriding providerName and description in built-in OAuthService providers (Thanks @smorin!) diff --git a/package-lock.json b/package-lock.json index 1400ef7..7d34f1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@raycast/utils", - "version": "2.3.2", + "version": "2.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@raycast/utils", - "version": "2.3.2", + "version": "2.3.3", "license": "MIT", "dependencies": { "dequal": "^2.0.3" diff --git a/package.json b/package.json index 90ffbc7..bc9e470 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@raycast/utils", - "version": "2.3.2", + "version": "2.3.3", "description": "Set of utilities to streamline building Raycast extensions", "author": "Raycast Technologies Ltd.", "homepage": "https://developers.raycast.com/utilities/getting-started", diff --git a/smoke-tests/package-lock.json b/smoke-tests/package-lock.json index 7d4169a..c5a491f 100644 --- a/smoke-tests/package-lock.json +++ b/smoke-tests/package-lock.json @@ -21,7 +21,7 @@ }, "..": { "name": "@raycast/utils", - "version": "2.3.0", + "version": "2.3.3", "license": "MIT", "dependencies": { "dequal": "^2.0.3" diff --git a/src/useFrecencySorting.ts b/src/useFrecencySorting.ts index 8de58d4..85e1761 100644 --- a/src/useFrecencySorting.ts +++ b/src/useFrecencySorting.ts @@ -2,17 +2,20 @@ import { useMemo, useCallback } from "react"; import { useLatest } from "./useLatest"; import { useCachedState } from "./useCachedState"; -// The algorithm below is inspired by the one used by Firefox: +// The algorithm below is based on Mozilla's exponential-decay frecency proposal: // https://wiki.mozilla.org/User:Jesse/NewFrecency -type Frecency = { +type LegacyFrecency = { lastVisited: number; frecency: number; }; +type StoredFrecency = number | LegacyFrecency; + const HALF_LIFE_DAYS = 10; const MS_PER_DAY = 24 * 60 * 60 * 1000; +const HALF_LIFE_MS = HALF_LIFE_DAYS * MS_PER_DAY; const VISIT_TYPE_POINTS = { Default: 100, @@ -20,16 +23,25 @@ const VISIT_TYPE_POINTS = { Bookmark: 140, }; -function getNewFrecency(item?: Frecency): Frecency { +// Store the timestamp at which the decaying score reaches 1. Later timestamps +// mean higher scores, so ranking only requires comparing the stored numbers. +function getNewFrecency(rankTimestamp?: number): number { const now = Date.now(); - const visitAgeInDays = item ? (now - item.lastVisited) / MS_PER_DAY : 0; - const decay = Math.exp(-(Math.log(2) / HALF_LIFE_DAYS) * visitAgeInDays); - const totalVisitValue = (item?.frecency ?? 0) * decay + VISIT_TYPE_POINTS.Default; - - return { - lastVisited: now, - frecency: totalVisitValue, - }; + const currentScore = rankTimestamp === undefined ? 0 : 2 ** ((rankTimestamp - now) / HALF_LIFE_MS); + return now + HALF_LIFE_MS * Math.log2(currentScore + VISIT_TYPE_POINTS.Default); +} + +function normalizeFrecencies( + storedFrecencies: Record, +): Record { + return Object.fromEntries( + Object.entries(storedFrecencies).map(([key, value]) => [ + key, + typeof value === "number" || value === undefined + ? value + : value.lastVisited + HALF_LIFE_MS * Math.log2(value.frecency), + ]), + ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -103,23 +115,24 @@ export function useFrecencySorting( const keyRef = useLatest(options?.key || defaultKey); const sortUnvisitedRef = useLatest(options?.sortUnvisited); - const [storedFrecencies, setStoredFrecencies] = useCachedState>( + const [storedFrecencies, setStoredFrecencies] = useCachedState>( `raycast_frecency_${options?.namespace}`, {}, ); + // Read legacy entries without writing on mount. The next visit or reset persists + // the normalized map using the latest cached state inside the updater. + const frecencies = useMemo(() => normalizeFrecencies(storedFrecencies), [storedFrecencies]); + const visitItem = useCallback( async function updateFrecency(item: T) { const itemKey = keyRef.current(item); setStoredFrecencies((storedFrecencies) => { - const frecency = storedFrecencies[itemKey]; - const newFrecency = getNewFrecency(frecency); + const frecencies = normalizeFrecencies(storedFrecencies); + frecencies[itemKey] = getNewFrecency(frecencies[itemKey]); - return { - ...storedFrecencies, - [itemKey]: newFrecency, - }; + return frecencies; }); }, [keyRef, setStoredFrecencies], @@ -130,10 +143,10 @@ export function useFrecencySorting( const itemKey = keyRef.current(item); setStoredFrecencies((storedFrecencies) => { - const newFrencencies = { ...storedFrecencies }; - delete newFrencencies[itemKey]; + const frecencies = normalizeFrecencies(storedFrecencies); + delete frecencies[itemKey]; - return newFrencencies; + return frecencies; }); }, [keyRef, setStoredFrecencies], @@ -145,28 +158,28 @@ export function useFrecencySorting( } return [...data].sort((a, b) => { - const frecencyA = storedFrecencies[keyRef.current(a)]; - const frecencyB = storedFrecencies[keyRef.current(b)]; + const frecencyA = frecencies[keyRef.current(a)]; + const frecencyB = frecencies[keyRef.current(b)]; // If a has a frecency, but b doesn't, a should come first - if (frecencyA && !frecencyB) { + if (frecencyA !== undefined && frecencyB === undefined) { return -1; } // If b has a frecency, but a doesn't, b should come first - if (!frecencyA && frecencyB) { + if (frecencyA === undefined && frecencyB !== undefined) { return 1; } - // If both frecencies are defined,put the one with the higher frecency first - if (frecencyA && frecencyB) { - return frecencyB.frecency - frecencyA.frecency; + // If both frecencies are defined, put the one with the higher frecency first + if (frecencyA !== undefined && frecencyB !== undefined) { + return frecencyB - frecencyA; } // If both frecencies are undefined, keep the original order return sortUnvisitedRef.current ? sortUnvisitedRef.current(a, b) : 0; }); - }, [storedFrecencies, data, keyRef, sortUnvisitedRef]); + }, [frecencies, data, keyRef, sortUnvisitedRef]); return { data: sortedData, visitItem, resetRanking }; } diff --git a/test/useFrecencySorting.test.tsx b/test/useFrecencySorting.test.tsx new file mode 100644 index 0000000..62e6110 --- /dev/null +++ b/test/useFrecencySorting.test.tsx @@ -0,0 +1,221 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Cache, resetMockCache } from "./raycast-api-mock"; +import { useFrecencySorting } from "../src/useFrecencySorting"; + +const DAY = 24 * 60 * 60 * 1000; +const HALF_LIFE = 10 * DAY; +const START = Date.UTC(2026, 0, 1); +const CACHE_KEY = "raycast_frecency_undefined"; +const cache = new Cache(); +const stale = { id: "stale" }; +const fresh = { id: "fresh" }; +const items = [stale, fresh]; + +describe("useFrecencySorting", () => { + beforeEach(() => { + resetMockCache(); + vi.spyOn(Date, "now").mockReturnValue(START); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("ranks a single visit today above a single visit a year ago", async () => { + const { result } = renderHook(() => useFrecencySorting(items)); + await act(async () => result.current.visitItem(stale)); + vi.mocked(Date.now).mockReturnValue(START + 365 * DAY); + await act(async () => result.current.visitItem(fresh)); + + expect(result.current.data).toEqual([fresh, stale]); + expect(items).toEqual([stale, fresh]); + }); + + it("ranks two visits today above four visits sixty days ago", async () => { + const { result, unmount } = renderHook(() => useFrecencySorting(items)); + for (let i = 0; i < 4; i++) { + await act(async () => result.current.visitItem(stale)); + } + vi.mocked(Date.now).mockReturnValue(START + 60 * DAY); + for (let i = 0; i < 2; i++) { + await act(async () => result.current.visitItem(fresh)); + } + + expect(result.current.data).toEqual([fresh, stale]); + unmount(); + const reopened = renderHook(() => useFrecencySorting(items)); + expect(reopened.result.current.data).toEqual([fresh, stale]); + }); + + it("lets frequent recent usage outweigh a more recent single visit", async () => { + const { result } = renderHook(() => useFrecencySorting([fresh, stale])); + for (let i = 0; i < 4; i++) { + await act(async () => result.current.visitItem(stale)); + } + vi.mocked(Date.now).mockReturnValue(START + DAY); + await act(async () => result.current.visitItem(fresh)); + + expect(result.current.data).toEqual([stale, fresh]); + }); + + it("preserves the relative ranking when both items have been unused for decades", async () => { + const { result, unmount } = renderHook(() => useFrecencySorting(items)); + await act(async () => result.current.visitItem(stale)); + vi.mocked(Date.now).mockReturnValue(START + DAY); + await act(async () => result.current.visitItem(fresh)); + unmount(); + + vi.mocked(Date.now).mockReturnValue(START + 100 * 365 * DAY); + const reopened = renderHook(() => useFrecencySorting(items)); + expect(reopened.result.current.data).toEqual([fresh, stale]); + }); + + it("ranks legacy entries correctly without writing to the cache on read", () => { + const stored = JSON.stringify({ + stale: { lastVisited: START, frecency: 400 }, + fresh: { lastVisited: START + 60 * DAY, frecency: 200 }, + }); + cache.set(CACHE_KEY, stored); + vi.mocked(Date.now).mockReturnValue(START + 60 * DAY); + + const { result } = renderHook(() => useFrecencySorting(items)); + + expect(result.current.data).toEqual([fresh, stale]); + expect(cache.get(CACHE_KEY)).toBe(stored); + }); + + it("reads mixed legacy entries and timestamps using a custom key and namespace", async () => { + const key = "raycast_frecency_mixed"; + const timestamp = START + 80 * DAY; + cache.set( + key, + JSON.stringify({ + "item-stale": { lastVisited: START, frecency: 128 }, + "item-fresh": timestamp, + }), + ); + const { result, unmount } = renderHook(() => + useFrecencySorting(items, { namespace: "mixed", key: (item) => `item-${item.id}` }), + ); + + expect(result.current.data).toEqual([fresh, stale]); + await act(async () => result.current.resetRanking({ id: "missing" })); + expect(JSON.parse(cache.get(key)!)).toEqual({ + "item-stale": START + 70 * DAY, + "item-fresh": timestamp, + }); + expect(cache.get(CACHE_KEY)).toBeUndefined(); + + const migrated = cache.get(key); + unmount(); + const reopened = renderHook(() => + useFrecencySorting(items, { namespace: "mixed", key: (item) => `item-${item.id}` }), + ); + await act(async () => reopened.result.current.resetRanking({ id: "missing" })); + expect(cache.get(key)).toBe(migrated); + }); + + it("migrates all entries on a visit and adds to the decayed legacy score", async () => { + const now = START + 60 * DAY; + cache.set( + CACHE_KEY, + JSON.stringify({ + stale: { lastVisited: START, frecency: 400 }, + fresh: { lastVisited: now, frecency: 200 }, + hidden: { lastVisited: START, frecency: 128 }, + }), + ); + vi.mocked(Date.now).mockReturnValue(now); + const { result, unmount } = renderHook(() => useFrecencySorting(items)); + + await act(async () => result.current.visitItem(stale)); + + const migrated = JSON.parse(cache.get(CACHE_KEY)!); + expect(Object.keys(migrated).sort()).toEqual(["fresh", "hidden", "stale"]); + expect(2 ** ((migrated.stale - now) / HALF_LIFE)).toBeCloseTo(106.25); + expect(2 ** ((migrated.fresh - now) / HALF_LIFE)).toBeCloseTo(200); + expect(migrated.hidden).toBe(START + 70 * DAY); + expect(result.current.data).toEqual([fresh, stale]); + + unmount(); + const reopened = renderHook(() => useFrecencySorting(items)); + expect(reopened.result.current.data).toEqual([fresh, stale]); + await act(async () => reopened.result.current.visitItem(stale)); + expect(reopened.result.current.data).toEqual([stale, fresh]); + const updated = JSON.parse(cache.get(CACHE_KEY)!); + expect(2 ** ((updated.stale - now) / HALF_LIFE)).toBeCloseTo(206.25); + }); + + it("migrates on reset and keeps the removed ranking absent after reopening", async () => { + cache.set( + CACHE_KEY, + JSON.stringify({ + stale: { lastVisited: START, frecency: 256 }, + fresh: { lastVisited: START, frecency: 128 }, + hidden: { lastVisited: START, frecency: 64 }, + }), + ); + const { result, unmount } = renderHook(() => useFrecencySorting(items)); + await act(async () => result.current.resetRanking(stale)); + + expect(JSON.parse(cache.get(CACHE_KEY)!)).toEqual({ + fresh: START + 70 * DAY, + hidden: START + 60 * DAY, + }); + expect(result.current.data).toEqual([fresh, stale]); + + unmount(); + const reopened = renderHook(() => useFrecencySorting(items)); + expect(reopened.result.current.data).toEqual([fresh, stale]); + }); + + it("preserves immediate updates from multiple hooks while migrating", async () => { + cache.set(CACHE_KEY, JSON.stringify({ stale: { lastVisited: START, frecency: 100 } })); + const first = renderHook(() => useFrecencySorting(items)); + const second = renderHook(() => useFrecencySorting(items)); + const firstVisit = first.result.current.visitItem; + const secondVisit = second.result.current.visitItem; + + await act(async () => { + await firstVisit(stale); + await secondVisit(fresh); + await secondVisit(stale); + }); + + const stored = JSON.parse(cache.get(CACHE_KEY)!); + expect(2 ** ((stored.stale - START) / HALF_LIFE)).toBeCloseTo(300); + expect(2 ** ((stored.fresh - START) / HALF_LIFE)).toBeCloseTo(100); + expect(first.result.current.data).toEqual([stale, fresh]); + expect(second.result.current.data).toEqual([stale, fresh]); + }); + + it("treats a zero timestamp as a visited item when sorting and updating", async () => { + cache.set(CACHE_KEY, JSON.stringify({ fresh: 0 })); + vi.mocked(Date.now).mockReturnValue(0); + const { result } = renderHook(() => useFrecencySorting(items)); + + expect(result.current.data).toEqual([fresh, stale]); + await act(async () => result.current.visitItem(fresh)); + const stored = JSON.parse(cache.get(CACHE_KEY)!); + expect(2 ** (stored.fresh / HALF_LIFE)).toBeCloseTo(101); + }); + + it("keeps visited items above unvisited items and applies sortUnvisited only to unvisited items", async () => { + const a = { id: "a" }; + const b = { id: "b" }; + const c = { id: "c" }; + const { result } = renderHook(() => + useFrecencySorting([c, b, a], { sortUnvisited: (a, b) => a.id.localeCompare(b.id) }), + ); + expect(result.current.data).toEqual([a, b, c]); + + await act(async () => result.current.visitItem(c)); + expect(result.current.data).toEqual([c, a, b]); + await act(async () => result.current.resetRanking(c)); + expect(result.current.data).toEqual([a, b, c]); + }); +});