Ctrl K
ComunidadGuías…

src/lib/pipeline-list.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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;
}