Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | 135x 85x 142x 142x 60x 59x 57x 56x 1x 195x 1x 25x 121x 50x 44x 44x 29x 121x 50x 50x 41x 41x 41x 41x 41x 41x 121x 50x 121x 19x 19x 19x 19x 19x 19x 19x 19x 24x 24x 19x 103x 38x 38x 102x 38x 38x 102x 25x 38x 32x 29x 10x 18x 10x 26x 7x | import type {
CollectionPreferenceItem,
RankBucket,
SortResultItem,
} from "@/types";
import { effectiveBucket } from "@/lib/results-buckets";
/** The computed-only item shape that SortResultItemRepository.create consumes. */
export interface MaterializedItem {
itemId: string;
computedRank: number;
computedBucket: RankBucket;
}
const BUCKET_PRIORITY: Record<RankBucket, number> = {
top: 0,
high: 1,
medium: 2,
low: 3,
};
/**
* Pure: rank a curator's preferences into computed items — tier first (spec §4),
* then the pair-derived orderRank within the tier (unordered rows last), then
* itemId. orderRank only exists for Love + Like, so it never lifts a lower tier.
*/
export function rankPreferencesToItems(
preferences: CollectionPreferenceItem[],
): MaterializedItem[] {
return [...preferences]
.sort((a, b) => {
const byTier = BUCKET_PRIORITY[a.rankBucket] - BUCKET_PRIORITY[b.rankBucket];
if (byTier !== 0) return byTier;
if (a.orderRank !== b.orderRank) {
if (a.orderRank === null) return 1;
if (b.orderRank === null) return -1;
return a.orderRank - b.orderRank;
}
return compareItemId(a.itemId, b.itemId);
})
.map((pref, index) => ({
itemId: pref.itemId,
computedRank: index,
computedBucket: pref.rankBucket,
}));
}
function compareItemId(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* Pure: clone a finalized result's items into the computed-only create shape,
* dropping any manual overrides (the repo hard-codes manualRank/manualBucket to
* null, so the clone carries no manual fields).
*/
export function cloneItemsFrom(items: SortResultItem[]): MaterializedItem[] {
return items.map((item) => ({
itemId: item.itemId,
computedRank: item.computedRank,
computedBucket: item.computedBucket,
}));
}
/** A hand-placed piece in the open draft: its 1-based slot in the loved list. */
export interface Pin {
itemId: string;
position: number;
pinnedAt: Date;
}
export interface RankingEntry<B extends RankBucket = RankBucket> {
itemId: string;
bucket: B;
pinned: boolean;
}
/** Love, Like, Maybe — the buckets the loved list (and its pins) cover. */
export type LovedBucket = Exclude<RankBucket, "low">;
/**
* Pure: lay pinned pieces into their slots and fill the rest in derived order
* (spec §4: later pair answers never move a pinned piece). A pin past either end
* clamps; two pins on one slot keep the newer there and move the older to the
* next free slot. Pins for pieces not in `derived` are ignored — nothing is ever
* added or lost.
*/
export function applyPins<T extends { itemId: string }>(derived: readonly T[], pins: readonly Pin[]): T[] {
const byId = new Map(derived.map((d) => [d.itemId, d]));
const ordered = pins
.flatMap((p) => {
const entry = byId.get(p.itemId);
return entry ? [{ ...p, entry }] : [];
})
.sort(
(a, b) =>
a.position - b.position ||
b.pinnedAt.getTime() - a.pinnedAt.getTime() ||
compareItemId(a.itemId, b.itemId),
);
const slots: (T | null)[] = derived.map(() => null);
const pinned = new Set<string>();
for (const pin of ordered) {
Iif (pinned.has(pin.itemId)) continue;
let i = Math.min(Math.max(pin.position, 1), slots.length) - 1;
while (i < slots.length && slots[i] !== null) i++;
if (i === slots.length) i = slots.lastIndexOf(null);
slots[i] = pin.entry;
pinned.add(pin.itemId);
}
const rest = derived.filter((d) => !pinned.has(d.itemId));
let next = 0;
return slots.map((slot) => slot ?? rest[next++]);
}
/**
* Pure: the pins a hand move writes — the same splice the list makes on screen.
* `shown` is the loved order as laid out now. The moved piece takes its slot and
* every other pin is re-stated at the slot it shows after the splice, so a dense
* list (every piece pinned after Make a revision) shifts the pieces it passes
* instead of wrapping one into the gap. Returns only the pins whose slot changes.
*/
export function placePin(
shown: readonly string[],
pins: readonly Pin[],
itemId: string,
position: number,
): Map<string, number> {
const from = shown.indexOf(itemId);
Iif (from === -1) return new Map([[itemId, position]]);
const to = Math.min(Math.max(position, 1), shown.length) - 1;
const next = [...shown];
next.splice(from, 1);
next.splice(to, 0, itemId);
const moves = new Map([[itemId, to + 1]]);
for (const p of pins) {
const slot = next.indexOf(p.itemId) + 1;
if (p.itemId !== itemId && slot > 0 && slot !== p.position) moves.set(p.itemId, slot);
}
return moves;
}
/**
* Pure: the live ranking (spec §4) — tier-first derived order for live pieces,
* pins applied inside the loved list (Love, Like, Maybe), Onward as its own row.
* Shared by the Ranking page and Save-and-share, so what you see is what is saved.
*/
export function buildRanking(
preferences: readonly CollectionPreferenceItem[],
liveItemIds: ReadonlySet<string>,
pins: readonly Pin[],
): { loved: RankingEntry<LovedBucket>[]; onward: RankingEntry[] } {
const ranked = rankPreferencesToItems(preferences.filter((p) => liveItemIds.has(p.itemId)));
const pinnedIds = new Set(pins.map((p) => p.itemId));
const lovedRanked = ranked.flatMap((m) =>
m.computedBucket === "low" ? [] : [{ itemId: m.itemId, bucket: m.computedBucket, pinned: pinnedIds.has(m.itemId) }],
);
const loved = applyPins(lovedRanked, pins);
const onward = ranked
.filter((m) => m.computedBucket === "low")
.map((m) => ({ itemId: m.itemId, bucket: m.computedBucket, pinned: false }));
return { loved, onward };
}
/**
* Pure: the open draft's pins. `manualRank` keeps its existing 0-based convention
* (the legacy "Adjust results" rank space); a Pin is the 1-based slot, so reading
* a row adds 1 here and every writer stores `position - 1`.
*/
export function pinsFromRows(rows: readonly SortResultItem[]): Pin[] {
return rows.flatMap((r) =>
r.manualRank === null ? [] : [{ itemId: r.itemId, position: r.manualRank + 1, pinnedAt: r.updatedAt }],
);
}
/** Pure: a saved ranking's loved order as 0-based slots, ready for manualRank (Onward excluded). */
export function savedOrderPositions(rows: readonly SortResultItem[]): Map<string, number> {
const ordered = [...rows].sort(
(a, b) => (a.manualRank ?? a.computedRank) - (b.manualRank ?? b.computedRank),
);
return new Map(
ordered.filter((r) => effectiveBucket(r) !== "low").map((r, i) => [r.itemId, i]),
);
}
/** Pure: how many of the open draft's pins a revision would move or drop (a pin already at its saved slot stays). */
export function pinsLostToRevision(draftRows: readonly SortResultItem[], savedSlots: ReadonlyMap<string, number>): number {
return draftRows.filter((r) => r.manualRank !== null && savedSlots.get(r.itemId) !== r.manualRank).length;
}
|