Ctrl K
ComunidadGuías…

src/lib/pipeline-filters.ts

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

Archivo original ↗Preguntar a KiBot
import type { Stage } from "@prisma/client";
import { TERMINAL_STAGES } from "@/lib/data/pipeline";
import { STALE_AFTER } from "@/lib/quiet";

/**
 * What the pipeline is currently showing.
 *
 * Pure, and shared by the page (which filters) and the toolbar (which builds
 * links), so there is one definition of what a filter means rather than a
 * server copy and a client copy that drift.
 *
 * The URL stays the state. Every parameter here is also whitelisted in
 * normaliseQuery (src/lib/data/views.ts) — a saved view is that string, so a
 * parameter this file understands and that file drops is a filter that
 * silently disappears the moment somebody saves the view.
 */
export type PipelineFilters = {
  stages: Stage[];
  /** ANDs with everything else now. It used to replace the stage set. */
  overdue: boolean;
  /**
   * Tag ids. The URL still spells it `src` — it was called "source" when
   * saved views started storing it, and renaming the parameter would break
   * every view anyone has already saved for the sake of tidiness.
   */
  tags: string[];
  companies: string[];
  resumes: string[];
  /** Minimum days sitting in the current stage. */
  waiting: number | null;
  /** Minimum days since ANYTHING happened. Not the same question as waiting. */
  quiet: number | null;
  search: string;
};

export const EMPTY_FILTERS: PipelineFilters = {
  stages: [],
  overdue: false,
  tags: [],
  companies: [],
  resumes: [],
  waiting: null,
  quiet: null,
  search: "",
};

const list = (value: string | undefined) =>
  (value ?? "")
    .split(",")
    .map((part) => part.trim())
    .filter(Boolean);

const positive = (value: string | undefined) => {
  const parsed = Number(value);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
};

/**
 * `f` still carries the stages, and still understands the two words it always
 * did. `closed` expands to the terminal stages instead of being a mode of its
 * own, which is what makes "closed, and only the ones I withdrew from"
 * expressible through a tag; `overdue` becomes a flag, which is what makes
 * "interviewing and overdue" work. Both spellings survive so saved views and
 * pasted links keep meaning what they meant — including the four stage names
 * that no longer exist, which map to the ones that replaced them.
 */
/**
 * What the four stages that were merged away used to be spelled.
 *
 * Every saved view is a stored query string, and every link anyone has ever
 * pasted is one too. Ten stages became six; without this, a view called
 * "Interviews this month" quietly matches nothing and looks like lost data.
 * The same argument as `src` still being the tag parameter: renaming a thing
 * in the URL breaks what people already saved, so the URL keeps both spellings
 * and the reader maps them.
 */
const RETIRED_STAGES: Record<string, Stage> = {
  SCREEN: "INTERVIEWING",
  INTERVIEW: "INTERVIEWING",
  FINAL: "INTERVIEWING",
  REJECTED: "LOST",
  WITHDRAWN: "LOST",
  GHOSTED: "LOST",
};

export function parsePipelineFilters(
  one: (key: string) => string | undefined,
  stages: readonly Stage[],
): PipelineFilters {
  const parts = list(one("f"));
  const picked = [
    ...new Set(
      parts
        .map((part) =>
          (stages as readonly string[]).includes(part) ? (part as Stage) : RETIRED_STAGES[part],
        )
        .filter(Boolean) as Stage[],
    ),
  ];
  const closed = parts.includes("closed");
  return {
    stages: closed ? [...new Set([...picked, ...TERMINAL_STAGES])] : picked,
    overdue: parts.includes("overdue"),
    tags: list(one("src")),
    companies: list(one("co")),
    resumes: list(one("cv")),
    waiting: positive(one("w")),
    quiet: positive(one("qd")),
    search: one("q")?.trim() ?? "",
  };
}

/** Anything at all narrowing the board, for "clear" and for the empty state. */
export function hasAnyFilter(filters: PipelineFilters): boolean {
  return (
    filters.stages.length > 0 ||
    filters.overdue ||
    filters.tags.length > 0 ||
    filters.companies.length > 0 ||
    filters.resumes.length > 0 ||
    filters.waiting !== null ||
    filters.quiet !== null ||
    Boolean(filters.search)
  );
}

/** The row shape the predicate needs. Satisfied by listApplications. */
export type FilterableApplication = {
  stage: Stage;
  nextFollowUpAt: Date | null;
  companyId: string;
  resumeId: string | null;
  daysInStage: number;
  quietDays: number;
  tags: { id: string; name: string }[];
  roleTitle: string;
  notes: string;
  location: string;
  workMode: string;
  jobDescription: string;
  company: { name: string };
};

/**
 * One predicate, applied to rows the page already has.
 *
 * Search runs here rather than in the Prisma `where` because half of what is
 * worth searching cannot be expressed there: a substring across a relation's
 * names is not something `has` can do, and "which of these mentioned Rust"
 * needs the description the query was not fetching to match on.
 */
export function matchesFilters(
  application: FilterableApplication,
  filters: PipelineFilters,
  now = Date.now(),
): boolean {
  if (filters.stages.length > 0 && !filters.stages.includes(application.stage)) return false;

  if (filters.overdue) {
    // A closed application cannot be overdue: nobody is waiting on it.
    if (TERMINAL_STAGES.includes(application.stage)) return false;
    if (application.nextFollowUpAt === null) return false;
    if (application.nextFollowUpAt.getTime() > now) return false;
  }

  if (filters.tags.length > 0) {
    const ids = new Set(application.tags.map((tag) => tag.id));
    if (!filters.tags.some((id) => ids.has(id))) return false;
  }
  if (filters.companies.length > 0 && !filters.companies.includes(application.companyId)) {
    return false;
  }
  if (filters.resumes.length > 0) {
    // "none" is a real answer to "which resume went out".
    const value = application.resumeId ?? "none";
    if (!filters.resumes.includes(value)) return false;
  }
  if (filters.waiting !== null && application.daysInStage < filters.waiting) return false;
  if (filters.quiet !== null) {
    // Same rule the card and the cell draw: a closed application is over and a
    // wishlist entry was never waiting, so neither can be "gone quiet" — and
    // closed rows have the largest quietDays in a workspace, so without this
    // they won every filter and every sort.
    if (STALE_AFTER[application.stage] === undefined) return false;
    if (application.quietDays < filters.quiet) return false;
  }
  if (filters.search) {
    const needle = filters.search.toLowerCase();
    const haystack = [
      application.company.name,
      application.roleTitle,
      application.notes,
      application.location,
      application.workMode,
      application.jobDescription,
      ...application.tags.map((tag) => tag.name),
    ]
      .join(" ")
      .toLowerCase();
    if (!haystack.includes(needle)) return false;
  }

  return true;
}

/** The URL for a set of filters. Keys are omitted when they carry nothing. */
export function buildPipelineQuery(input: {
  view?: string;
  filters: PipelineFilters;
  sort?: string;
  dir?: string;
  month?: string;
}): string {
  const { filters } = input;
  const params = new URLSearchParams();
  if (input.view && input.view !== "board") params.set("view", input.view);

  const f = [...filters.stages, ...(filters.overdue ? ["overdue"] : [])];
  if (f.length > 0) params.set("f", f.join(","));
  if (filters.tags.length > 0) params.set("src", filters.tags.join(","));
  if (filters.companies.length > 0) params.set("co", filters.companies.join(","));
  if (filters.resumes.length > 0) params.set("cv", filters.resumes.join(","));
  if (filters.waiting !== null) params.set("w", String(filters.waiting));
  if (filters.quiet !== null) params.set("qd", String(filters.quiet));
  if (filters.search) params.set("q", filters.search);
  if (input.sort) params.set("sort", input.sort);
  if (input.dir) params.set("dir", input.dir);
  if (input.month) params.set("month", input.month);

  const query = params.toString();
  return query ? `/applications?${query}` : "/applications";
}

/** Toggle one value in one of the list dimensions. */
export function toggleIn(values: string[], value: string): string[] {
  return values.includes(value) ? values.filter((item) => item !== value) : [...values, value];
}