diff --git a/src/useFrecencySorting.ts b/src/useFrecencySorting.ts index 8de58d4..440fa08 100644 --- a/src/useFrecencySorting.ts +++ b/src/useFrecencySorting.ts @@ -20,11 +20,18 @@ const VISIT_TYPE_POINTS = { Bookmark: 140, }; +function getDecayedFrecency(item: Frecency | undefined, now: number): number { + if (!item) { + return 0; + } + const visitAgeInDays = (now - item.lastVisited) / MS_PER_DAY; + const decay = Math.exp(-(Math.log(2) / HALF_LIFE_DAYS) * visitAgeInDays); + return item.frecency * decay; +} + function getNewFrecency(item?: Frecency): Frecency { 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; + const totalVisitValue = getDecayedFrecency(item, now) + VISIT_TYPE_POINTS.Default; return { lastVisited: now, @@ -144,6 +151,8 @@ export function useFrecencySorting( return []; } + const now = Date.now(); + return [...data].sort((a, b) => { const frecencyA = storedFrecencies[keyRef.current(a)]; const frecencyB = storedFrecencies[keyRef.current(b)]; @@ -158,9 +167,10 @@ export function useFrecencySorting( return 1; } - // If both frecencies are defined,put the one with the higher frecency first + // If both frecencies are defined, put the one with the higher frecency first. + // Scores are decayed to the same point in time so that old visits count less than recent ones. if (frecencyA && frecencyB) { - return frecencyB.frecency - frecencyA.frecency; + return getDecayedFrecency(frecencyB, now) - getDecayedFrecency(frecencyA, now); } // If both frecencies are undefined, keep the original order diff --git a/test/useFrecencySorting.test.tsx b/test/useFrecencySorting.test.tsx new file mode 100644 index 0000000..1d90940 --- /dev/null +++ b/test/useFrecencySorting.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +import React from "react"; +import { act, cleanup, render } 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 MS_PER_DAY = 24 * 60 * 60 * 1000; +const NOW = new Date("2026-01-01T00:00:00.000Z").getTime(); + +type Item = { id: string }; + +function Probe({ items }: { items: Item[] }) { + const { data } = useFrecencySorting(items, { namespace: "test" }); + return <>{data.map((item) => item.id).join(",")}; +} + +describe("useFrecencySorting", () => { + beforeEach(() => { + resetMockCache(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("ranks a recent visit above an old one with the same score", () => { + new Cache().set( + "raycast_frecency_test", + JSON.stringify({ + old: { lastVisited: NOW - 365 * MS_PER_DAY, frecency: 100 }, + recent: { lastVisited: NOW - MS_PER_DAY, frecency: 100 }, + }), + ); + + const view = render(); + + expect(view.container.textContent).toBe("recent,old"); + }); + + it("lets an item visited a few times recently outrank one visited a few more times long ago", async () => { + let visit: (item: Item) => Promise = async () => undefined; + function VisitProbe({ items }: { items: Item[] }) { + const { data, visitItem } = useFrecencySorting(items, { namespace: "test" }); + visit = visitItem; + return <>{data.map((item) => item.id).join(",")}; + } + + const view = render(); + + // "stale" is visited 4 times, 60 days ago + vi.setSystemTime(NOW - 60 * MS_PER_DAY); + for (let i = 0; i < 4; i++) { + await act(() => visit({ id: "stale" })); + } + + // "fresh" is visited twice, today + vi.setSystemTime(NOW); + for (let i = 0; i < 2; i++) { + await act(() => visit({ id: "fresh" })); + } + + expect(view.container.textContent).toBe("fresh,stale"); + }); + + it("still ranks the more frequently visited item first when visits are equally recent", () => { + new Cache().set( + "raycast_frecency_test", + JSON.stringify({ + once: { lastVisited: NOW - MS_PER_DAY, frecency: 100 }, + often: { lastVisited: NOW - MS_PER_DAY, frecency: 300 }, + }), + ); + + const view = render(); + + expect(view.container.textContent).toBe("often,once,never"); + }); +});