src/lib/pipeline-list.ts
Rama main · réplica de lectura del repositorio original.
import type { Stage } from "@prisma/client";
import { STALE_AFTER } from "@/lib/quiet";
/**
* The list view's shape and sorting.
*
* Split out from the component because the page sorts on the server and the
* component is a client component — a `"use client"` file's exports cannot be
* called from the server, and the failure is a runtime 500 rather than a type
* error, so it is worth keeping the pure half somewhere it cannot happen.
*/
export const LIST_SORTS = [
"followUp",
"company",
"stage",
"updated",
"salary",
"waiting",
"quiet",
] as const;
export type ListSort = (typeof LIST_SORTS)[number];
export function parseSort(value: string | undefined): ListSort {
return (LIST_SORTS as readonly string[]).includes(value ?? "")
? (value as ListSort)
: "followUp";
}
export type ListRow = {
id: string;
company: string;
roleTitle: string;
stage: Stage;
location: string;
salaryRange: string;
nextFollowUpAt: string | null;
activityCount: number;
updatedAt: string;
/** Days since the stage last changed. The "how long has this been sitting" number. */
daysInStage: number;
/** Days since anything at all happened. Chasing is decided on this one. */
quietDays: number;
/** The posting, for the row's own actions. Empty when there never was one. */
jobUrl: string;
/** Null when logos are off, or no domain could be worked out. */
domain: string | null;
};
/**
* What a row is built from. Structural, so listApplications' own return type
* satisfies it without either side importing the other.
*/
export type ListSource = {
id: string;
company: { name: string };
roleTitle: string;
stage: Stage;
location: string;
salaryRange: string;
nextFollowUpAt: Date | null;
updatedAt: Date;
daysInStage: number;
quietDays: number;
jobUrl: string;
_count: { activities: number };
};
/** One mapping, shared by the table and the export, so they cannot drift. */
export function toListRow(application: ListSource, domain: string | null): ListRow {
return {
id: application.id,
company: application.company.name,
roleTitle: application.roleTitle,
stage: application.stage,
location: application.location,
salaryRange: application.salaryRange,
nextFollowUpAt: application.nextFollowUpAt?.toISOString() ?? null,
activityCount: application._count.activities,
updatedAt: application.updatedAt.toISOString(),
daysInStage: application.daysInStage,
quietDays: application.quietDays,
jobUrl: application.jobUrl,
domain,
};
}
const STAGE_ORDER: Stage[] = [
"OFFER",
"INTERVIEWING",
"APPLIED",
"WISHLIST",
"ACCEPTED",
"LOST",
];
/**
* Sorting happens here rather than in the query because the useful default —
* soonest follow-up first, then everything with no date at all — is not an
* ordering Postgres gives you for free, and the list is a person's own
* pipeline: tens of rows, not thousands.
*/
export function sortRows(rows: ListRow[], sort: ListSort, desc: boolean): ListRow[] {
const sorted = [...rows].sort((a, b) => {
switch (sort) {
case "company":
return a.company.localeCompare(b.company) || a.roleTitle.localeCompare(b.roleTitle);
case "stage":
return STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage);
case "updated":
return b.updatedAt.localeCompare(a.updatedAt);
// Longest wait first: the useful question is what has been ignored, not
// what was touched a minute ago.
case "waiting":
return b.daysInStage - a.daysInStage;
// Same argument, different question: what nobody has touched, rather
// than what has not moved. Rows the rule gives no threshold — closed,
// and the wishlist — sort last rather than first: they have the largest
// numbers in the workspace and none of them mean anything.
case "quiet": {
const quiet = (row: ListRow) =>
STALE_AFTER[row.stage] === undefined ? -1 : row.quietDays;
return quiet(b) - quiet(a);
}
case "salary":
// No salary sorts last either way: a blank is not "cheapest".
return (salaryFloor(b.salaryRange) || -1) - (salaryFloor(a.salaryRange) || -1);
case "followUp":
default:
if (!a.nextFollowUpAt && !b.nextFollowUpAt) return b.updatedAt.localeCompare(a.updatedAt);
if (!a.nextFollowUpAt) return 1;
if (!b.nextFollowUpAt) return -1;
return a.nextFollowUpAt.localeCompare(b.nextFollowUpAt);
}
});
return desc ? sorted.reverse() : sorted;
}
/** First number that looks like money, so "$210k – $260k" sorts on 210000. */
function salaryFloor(range: string): number {
const match = range.replace(/,/g, "").match(/(\d+(?:\.\d+)?)\s*(k|m)?/i);
if (!match) return 0;
const value = Number(match[1]);
const unit = match[2]?.toLowerCase();
return unit === "k" ? value * 1000 : unit === "m" ? value * 1e6 : value;
}