Files
kolaytercih/src/lib/db.ts
bilalgursen 5c583ac8b4 feat(veri): Program.varyant — burs/dil/öğretim türü etiketi tek kaynaktan (program_ozellik)
programs.isim burs/dil varyantını taşımıyor (Başkent Tıp 6 satırda da
"Tıp"); ayırt edici alanlar program_ozellik.burs_orani / ogrenim_dili /
ogrenim_turu. SELECT_COLS üç ilişkili alt sorgu kazanır, programSatiri
varyantEtiketi ile "Burslu · İngilizce" gibi tek etiket üretir; devlet /
Türkçe / örgün programda null. Havuz JSON'una da girer: model aynı adlı
satırları ayırt edebilsin.

Tablo yokluğu koruması bilinçli yok: data/yokatlas.db depoda ve Docker
seed'inde (Dockerfile) program_ozellik tablosuyla geliyor. programs_arsiv
sorguları dokunulmadı (arşivde özellik yok).

Test: pnpm tsx scripts/eval/varyant-denetim.ts (salt okur, çıkış 0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 23:12:03 +03:00

725 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz
// node (tsx) altında import ediyor; server-only paketi orada fırlar.
import path from "node:path";
import Database from "better-sqlite3";
import { KATEGORILER, kategoriBul } from "./kategoriler";
import { EFEKTIF_SIRA_SQL, ILK_YIL, SON_YIL, YILLAR } from "./veri-yillari";
import { turkishSlugify } from "./slug";
import { varyantEtiketi } from "./program-varyant";
import {
PUAN_TURLERI,
type ArsivProgram,
type DilimKey,
type KontenjanDilimi,
type KontenjanTurleri,
type KontenjanTuruAnahtari,
type Program,
type ProgramGecmisSatiri,
type ProgramNetSatiri,
type PuanTuruKey,
type RankResults,
type SihirbazFacetleri,
type UniturGrubu,
} from "@/types/yokatlas";
// Dev'de hot-reload başına yeni bağlantı açılmasın diye global cache
const globalForDb = globalThis as unknown as { yokatlasDb?: Database.Database };
export function getDb(): Database.Database {
if (!globalForDb.yokatlasDb) {
globalForDb.yokatlasDb = new Database(
path.join(process.cwd(), "data", "yokatlas.db"),
{ readonly: true, fileMustExist: true }
);
}
return globalForDb.yokatlasDb;
}
// Yıl ekli kolonlar yalnız burada, `YILLAR`'dan üretilerek adlandırılır; DB
// dışına yılsız `Program` çıkar. SELECT_COLS ile okunan her satır
// `programSatiri` eşleyicisinden geçmelidir (ham satır `Program` değildir).
// Burs/dil/öğretim türü `program_ozellik`'ten ilişkili alt sorguyla gelir
// (id PRIMARY KEY). Bütün SELECT_COLS kullanımları takma adsız `FROM programs`
// olduğu için `programs.id` çözümlenir; `programs_arsiv` (ARSIV_SELECT) bu
// kolonları almaz (arşivde özellik yok). Tablo yokluğu koruması gerekmez:
// data/yokatlas.db depoda ve Docker seed'inde tabloyla gelir.
export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure,
${YILLAR.map((yil) => `sira${yil} AS s${yil}`).join(", ")},
puan${SON_YIL} AS puanSon, kontenjan${SON_YIL} AS kontenjanSon, yerlesen${SON_YIL} AS yerlesenSon,
(SELECT burs_orani FROM program_ozellik o WHERE o.id = programs.id) AS bursOrani,
(SELECT ogrenim_dili FROM program_ozellik o WHERE o.id = programs.id) AS ogrenimDili,
(SELECT ogrenim_turu FROM program_ozellik o WHERE o.id = programs.id) AS ogrenimTuru`;
// Son yılın sıralaması yoksa (az yerleşen/yeni program) bir önceki yıla düşer.
// İfade indeks sözleşmesidir; tek kaynağı lib/veri-yillari.ts.
export const EFEKTIF_SIRA = EFEKTIF_SIRA_SQL;
/** SELECT_COLS ile okunan ham satır: sıra kolonları `s<yıl>` takma adıyla,
* varyant ise üç ham özellik alanı olarak gelir. */
export type ProgramHamSatiri = Omit<
Program,
"siraGecmisi" | "efektifSira" | "siraSon" | "varyant"
> &
Record<`s${number}`, number | null> & {
bursOrani: string | null;
ogrenimDili: string | null;
ogrenimTuru: string | null;
};
/** Ham DB satırını yılsız `Program`'a çevirir. */
export function programSatiri(row: ProgramHamSatiri): Program {
const siraGecmisi = YILLAR.map((yil) => row[`s${yil}`] ?? null);
const siraSon = siraGecmisi[siraGecmisi.length - 1] ?? null;
return {
id: row.id,
isim: row.isim,
universite: row.universite,
unitur: row.unitur,
il: row.il,
fakulte: row.fakulte,
tur: row.tur,
sure: row.sure,
siraGecmisi,
// EFEKTIF_SIRA ile aynı kural: son yıl, yoksa bir önceki yıl
efektifSira: siraSon ?? siraGecmisi[siraGecmisi.length - 2] ?? null,
siraSon,
puanSon: row.puanSon,
kontenjanSon: row.kontenjanSon,
yerlesenSon: row.yerlesenSon,
varyant: varyantEtiketi(row),
};
}
/**
* Kullanıcının başarı sıralamasına göre programları üç dilime ayırır.
* Sıralamada küçük sayı daha iyidir; program taban sıralaması kullanıcının
* sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü
* demektir (hayal), büyükse daha güvenli demektir (garanti).
*/
/**
* Danışman profilinden gelen program özelliği süzgeci (program_ozellik
* tablosu; bkz. lib/program-ozellik.ts). Tablo yoksa süzgeç uygulanmaz.
* Devlet programlarında ücret/burs NULL'dur ve her zaman geçer.
*/
export type OzellikSuzgeci = {
dil?: "Türkçe" | "İngilizce";
yalnizOrgun?: boolean;
yalnizBurslu?: boolean;
ucretUst?: number;
};
let ozellikTablosuVar: boolean | null = null;
function ozellikTablosuVarMi(): boolean {
if (ozellikTablosuVar == null) {
ozellikTablosuVar =
getDb()
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'")
.get() !== undefined;
}
return ozellikTablosuVar;
}
function ozellikFiltreSql(o?: OzellikSuzgeci): { sql: string; args: (string | number)[] } {
if (!o) return { sql: "", args: [] };
const kosul: string[] = [];
const args: (string | number)[] = [];
if (o.dil) {
kosul.push("ogrenim_dili LIKE ?");
args.push(`${o.dil}%`);
}
if (o.yalnizOrgun) kosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')");
if (o.yalnizBurslu) kosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')");
if (o.ucretUst) {
kosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)");
args.push(o.ucretUst);
}
if (kosul.length === 0) return { sql: "", args: [] };
if (!ozellikTablosuVarMi()) return { sql: "", args: [] };
return {
sql: `AND id IN (SELECT id FROM program_ozellik WHERE ${kosul.join(" AND ")})`,
args,
};
}
/**
* Başarı sırası barajı: `program_ozellik.min_basari_sirasi` programı tercih
* edebilmek için gereken en düşük sıradır (YÖK Atlas'tan, kılavuz yılına ait;
* ör. tıp 50.000). Adayın sırası bundan kötüyse o programı yazamaz. Barajı
* boş olan program (barajsız ya da veride eksik) süzülmez.
*/
function barajFiltreSql(sira: number): { sql: string; args: number[] } {
if (!ozellikTablosuVarMi()) return { sql: "", args: [] };
return {
sql: "AND id NOT IN (SELECT id FROM program_ozellik WHERE min_basari_sirasi < ?)",
args: [sira],
};
}
function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
if (grup === "vakif")
return { sql: "AND unitur IS NOT NULL AND unitur <> ?", args: ["DEVLET"] };
return { sql: "", args: [] };
}
/**
* Dilim sınırları. Pencereler çarpan bazlı DEĞİL, açık uçlu: hayal adayın
* üstündeki her şeyi, garanti 1.4× eşiğinin altındaki her şeyi kapsar.
* "Yakından uzağa" sıralama + limit/offset sayesinde uçtaki gerçekçi olmayan
* programlar ancak sayfalandıkça görünür; eski ×0.5/×3 pencereleri küçük
* sıralamalarda (ör. 87) hiç sonuç bırakmıyordu.
*/
export function dilimAraligi(
sira: number,
dilim: DilimKey
): { alt: number; ust: number | null } {
const dengeliUst = Math.round(sira * 1.4);
if (dilim === "hayal") return { alt: 1, ust: sira - 1 };
if (dilim === "dengeli") return { alt: sira, ust: dengeliUst };
return { alt: dengeliUst + 1, ust: null };
}
/**
* Tek dilimi "yakından uzağa" sıralı ve sayfalı getirir: hayal adayın
* sıralamasından yukarı (taban DESC), dengeli/garanti aşağı (taban ASC).
* toplam, limit'ten bağımsız olarak dilimdeki tüm eşleşme sayısıdır.
*/
export function dilimAra(
sira: number,
turKey: PuanTuruKey,
dilim: DilimKey,
opts: {
il?: string;
iller?: string[];
uniturGrubu?: UniturGrubu;
ozellik?: OzellikSuzgeci;
/** Adayın sırasıyla yazamayacağı (baraj dışı) programları çıkar */
barajDisiHaric?: boolean;
limit?: number;
offset?: number;
} = {}
): { programlar: Program[]; toplam: number } {
const db = getDb();
const tur = PUAN_TURLERI[turKey];
const onlisans = turKey === "tyt" ? 1 : 0;
const limit = opts.limit ?? 12;
const offset = opts.offset ?? 0;
// il (tekil, geriye uyumlu) veya iller (çoklu) — ikisi de verilirse birleşir
const illerListe = [
...(opts.il ? [opts.il] : []),
...(opts.iller ?? []),
].filter((v, i, a) => v && a.indexOf(v) === i);
const ilFiltre =
illerListe.length > 0
? `AND il IN (${illerListe.map(() => "?").join(",")})`
: "";
const unitur = uniturFiltreSql(opts.uniturGrubu);
const ozellik = ozellikFiltreSql(opts.ozellik);
const baraj = opts.barajDisiHaric
? barajFiltreSql(sira)
: { sql: "", args: [] };
const { alt, ust } = dilimAraligi(sira, dilim);
if (ust != null && ust < alt) return { programlar: [], toplam: 0 };
const siraKosulu =
ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`;
const siraArgs = ust != null ? [alt, ust] : [alt];
const args = [
tur,
onlisans,
...illerListe,
...unitur.args,
...ozellik.args,
...baraj.args,
...siraArgs,
];
const base = `FROM programs
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql} ${ozellik.sql} ${baraj.sql}
AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${siraKosulu}`;
// hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa
const yon = dilim === "hayal" ? "DESC" : "ASC";
const satirlar = db
.prepare(
`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ${yon} LIMIT ? OFFSET ?`
)
.all(...args, limit, offset) as ProgramHamSatiri[];
const toplam = (
db.prepare(`SELECT COUNT(*) AS adet ${base}`).get(...args) as {
adet: number;
}
).adet;
return { programlar: satirlar.map(programSatiri), toplam };
}
export function searchByRank(
sira: number,
turKey: PuanTuruKey,
opts: {
il?: string;
iller?: string[];
uniturGrubu?: UniturGrubu;
ozellik?: OzellikSuzgeci;
limitPerBucket?: number;
} = {}
): RankResults {
const ortak = {
il: opts.il,
iller: opts.iller,
uniturGrubu: opts.uniturGrubu,
ozellik: opts.ozellik,
limit: opts.limitPerBucket ?? 12,
};
const hayal = dilimAra(sira, turKey, "hayal", ortak);
const dengeli = dilimAra(sira, turKey, "dengeli", ortak);
const garanti = dilimAra(sira, turKey, "garanti", ortak);
return {
hayal: hayal.programlar,
dengeli: dengeli.programlar,
garanti: garanti.programlar,
toplam: {
hayal: hayal.toplam,
dengeli: dengeli.toplam,
garanti: garanti.toplam,
},
};
}
/**
* Aday havuzunun ham girdisi: üç dilimin TAMAMI, yakından uzağa sıralı
* (limit yok). Alan (kategori) DB'de kolon değil, JS'te isim+fakülteden
* türüyor; süzgeç limitten SONRA uygulanınca sıraya en yakın satırlar her
* alandan karışık geldiği için seçilen alan olduğundan az görünüyordu. Bu
* yüzden havuz dilimin tamamını alır, alan/il süzgecini kendisi uygular ve
* en son kırpar (bkz. lib/rapor-havuzu.ts). En büyük puan türü ~9 bin satır.
* Adayın başarı sırası barajını geçemediği programlar buraya hiç girmez.
*/
export function havuzAdaylari(
sira: number,
turKey: PuanTuruKey,
opts: { uniturGrubu?: UniturGrubu; ozellik?: OzellikSuzgeci } = {}
): Record<DilimKey, Program[]> {
// LIMIT -1: SQLite'ta "sınırsız"
const ortak = { ...opts, barajDisiHaric: true, limit: -1 };
return {
hayal: dilimAra(sira, turKey, "hayal", ortak).programlar,
dengeli: dilimAra(sira, turKey, "dengeli", ortak).programlar,
garanti: dilimAra(sira, turKey, "garanti", ortak).programlar,
};
}
/**
* Programa yerleşen son kişinin yıl bazlı netleri, yeniden eskiye sıralı.
* Veri scripts/detay.ts ile YÖK Atlas /api/netler/search'ten doldurulur.
*/
export function netlerGetir(programId: string): ProgramNetSatiri[] {
return getDb()
.prepare(
`SELECT yil, puanTuru, tabanPuan, obp, katsayi,
tytTrkNet, tytSosNet, tytMatNet, tytFenNet,
aytMatNet, aytFizNet, aytKimNet, aytBioNet,
aytTdeNet, aytTrh1Net, aytCog1Net, aytTrh2Net, aytCog2Net,
aytDinNet, aytFelNet, ydtYdilNet
FROM netler WHERE program_id = ? ORDER BY yil DESC`
)
.all(programId) as ProgramNetSatiri[];
}
/** Güncel kılavuzdaki tek program; arşivlenmiş ya da bilinmeyen id'de null. */
export function programGetir(id: string): Program | null {
const row = getDb()
.prepare(`SELECT ${SELECT_COLS} FROM programs WHERE id = ?`)
.get(id) as ProgramHamSatiri | undefined;
return row ? programSatiri(row) : null;
}
// ── Yıl bazlı geçmiş, kontenjan türleri, arşiv ───────────────────────────
// Bu alanlar `Program` tipine girmez: liste satırları client'a serileşiyor,
// her satırı 4×6 kolonla şişirmek istemiyoruz. Program ayrıntı çekmecesi
// (/api/program-ayrinti), danışman araçları ve uyum puanı buradan okur.
let programKolonlari: Set<string> | null = null;
/** `programs` tablosunun kolon adları (bir kez okunur). */
function programKolonlariGetir(): Set<string> {
if (!programKolonlari) {
programKolonlari = new Set(
(getDb().prepare("PRAGMA table_info(programs)").all() as { name: string }[]).map(
(c) => c.name
)
);
}
return programKolonlari;
}
/**
* Programın yıl bazlı geçmişi, YENİDEN ESKİYE. Veri penceresi `YILLAR`;
* kolonu olmayan yıl atlanır. Bilinmeyen id'de boş dizi.
*/
export function programGecmisiGetir(id: string): ProgramGecmisSatiri[] {
const kolonlar = programKolonlariGetir();
const yillar = YILLAR.filter((y) => kolonlar.has(`sira${y}`));
if (yillar.length === 0) return [];
const secim = yillar
.map((y) => `sira${y} AS s${y}, puan${y} AS p${y}, kontenjan${y} AS k${y}, yerlesen${y} AS y${y}`)
.join(", ");
const row = getDb()
.prepare(`SELECT ${secim} FROM programs WHERE id = ?`)
.get(id) as Record<string, number | null> | undefined;
if (!row) return [];
return [...yillar].reverse().map((yil) => ({
yil,
sira: row[`s${yil}`] ?? null,
puan: row[`p${yil}`] ?? null,
kontenjan: row[`k${yil}`] ?? null,
yerlesen: row[`y${yil}`] ?? null,
}));
}
/** Kontenjan türü kolonu öneki → KontenjanTurleri alanı. */
const KONTENJAN_TURU_KOLON = {
gk: "genel",
obk: "okulBirincisi",
sgy: "sehitGaziYakini",
dprm: "depremzede",
y34k: "kadin34",
} as const;
/** KontenjanTurleri alanı → kolon öneki (SQL süzgeçleri `${onek}${kontenjanTuruYili()}`). */
export const KONTENJAN_TURU_ONEK: Record<KontenjanTuruAnahtari, string> = {
genel: "gk",
okulBirincisi: "obk",
sehitGaziYakini: "sgy",
depremzede: "dprm",
kadin34: "y34k",
};
let kontenjanTuruYiliOnbellek: number | null | undefined;
/**
* Kontenjan türü kırılımının bulunduğu en yeni yıl (`gk<yil>` kolonu olan).
* Kırılım bir sonraki kılavuzla açıklandığı için SON_YIL'dan bir geride
* olabilir; hiç yoksa null (eski DB).
*/
export function kontenjanTuruYili(): number | null {
if (kontenjanTuruYiliOnbellek !== undefined) return kontenjanTuruYiliOnbellek;
const kolonlar = programKolonlariGetir();
let bulunan: number | null = null;
for (let y = SON_YIL; y >= ILK_YIL; y--) {
if (kolonlar.has(`gk${y}`) && kolonlar.has(`y34k${y}`)) {
bulunan = y;
break;
}
}
kontenjanTuruYiliOnbellek = bulunan;
return bulunan;
}
function kontenjanTurleriSatiri(
yil: number,
row: Record<string, number | null>
): KontenjanTurleri {
const dilim = (onek: string): KontenjanDilimi => ({
kontenjan: row[`${onek}${yil}`] ?? null,
yerlesen: row[`${onek}Y${yil}`] ?? null,
});
return {
yil,
genel: dilim("gk"),
okulBirincisi: dilim("obk"),
sehitGaziYakini: dilim("sgy"),
depremzede: dilim("dprm"),
kadin34: dilim("y34k"),
};
}
function kontenjanTuruSecimi(yil: number): string {
return Object.keys(KONTENJAN_TURU_KOLON)
.map((onek) => `${onek}${yil}, ${onek}Y${yil}`)
.join(", ");
}
/** Tek programın kontenjan türleri; kırılım kolonu ya da program yoksa null. */
export function kontenjanTurleriGetir(id: string): KontenjanTurleri | null {
const yil = kontenjanTuruYili();
if (yil == null) return null;
const row = getDb()
.prepare(`SELECT ${kontenjanTuruSecimi(yil)} FROM programs WHERE id = ?`)
.get(id) as Record<string, number | null> | undefined;
return row ? kontenjanTurleriSatiri(yil, row) : null;
}
const IN_PARCA = 500;
/** Çok programın kontenjan türleri (uyum puanı, arama sonuçları); id → satır. */
export function kontenjanTurleriGetirToplu(
ids: readonly string[]
): Record<string, KontenjanTurleri> {
const sonuc: Record<string, KontenjanTurleri> = {};
const yil = kontenjanTuruYili();
if (yil == null || ids.length === 0) return sonuc;
const db = getDb();
for (let i = 0; i < ids.length; i += IN_PARCA) {
const parca = ids.slice(i, i + IN_PARCA);
const satirlar = db
.prepare(
`SELECT id, ${kontenjanTuruSecimi(yil)} FROM programs WHERE id IN (${parca.map(() => "?").join(",")})`
)
.all(...parca) as (Record<string, number | null> & { id: string })[];
for (const r of satirlar) sonuc[r.id] = kontenjanTurleriSatiri(yil, r);
}
return sonuc;
}
// Katlanmış (Türkçe harf duyarsız) ad eşleştirme: SQLite LIKE "isletme"yi
// İşletme'yle, "bogazici"yi Boğaziçi'yle eşlemez. Kolonun ayrık değerleri
// (birkaç bin) tablo başına bir kez okunur, eşleşme JS'te yapılır. Danışman
// araçları (program_ara) ve arşiv araması aynı yardımcıyı kullanır.
type AyrikDeger = { ham: string; katli: string };
const ayrikDegerler = new Map<string, AyrikDeger[]>();
export function ayrikListe(
kolon: "isim" | "universite",
tablo: "programs" | "programs_arsiv" = "programs"
): AyrikDeger[] {
const anahtar = `${tablo}.${kolon}`;
let liste = ayrikDegerler.get(anahtar);
if (!liste) {
liste = (getDb().prepare(`SELECT DISTINCT ${kolon} AS v FROM ${tablo}`).all() as { v: string | null }[])
.filter((x): x is { v: string } => x.v != null)
.map((x) => ({ ham: x.v, katli: turkishSlugify(x.v) }));
ayrikDegerler.set(anahtar, liste);
}
return liste;
}
/** Kolonun, katlanmış hâli `katliSorgu`yu içeren ham değerleri. */
export function katliEslesenler(
kolon: "isim" | "universite",
katliSorgu: string,
tablo: "programs" | "programs_arsiv" = "programs"
): string[] {
return ayrikListe(kolon, tablo)
.filter((x) => x.katli.includes(katliSorgu))
.map((x) => x.ham);
}
let arsivTablosuVar: boolean | null = null;
function arsivTablosuVarMi(): boolean {
if (arsivTablosuVar == null) {
arsivTablosuVar =
getDb()
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='programs_arsiv'")
.get() !== undefined;
}
return arsivTablosuVar;
}
const ARSIV_SELECT = `id, isim, universite, unitur, il, fakulte, tur, sure, son_kilavuz_yili AS sonKilavuzYili, eski_kod AS eskiKod,
${YILLAR.map((y) => `sira${y} AS s${y}, puan${y} AS p${y}, kontenjan${y} AS k${y}, yerlesen${y} AS y${y}`).join(", ")}`;
function arsivSatiri(row: Record<string, unknown>): ArsivProgram {
// Dolu son yerleştirme yılı: sırası ya da kontenjanı olan en yeni yıl
let sonYil: number | null = null;
for (const y of [...YILLAR].reverse()) {
if (row[`s${y}`] != null || row[`k${y}`] != null) {
sonYil = y;
break;
}
}
const deger = (onek: string) =>
sonYil == null ? null : ((row[`${onek}${sonYil}`] as number | null) ?? null);
return {
id: String(row.id),
isim: String(row.isim),
universite: String(row.universite),
unitur: (row.unitur as string | null) ?? null,
il: (row.il as string | null) ?? null,
fakulte: (row.fakulte as string | null) ?? null,
tur: String(row.tur),
sure: (row.sure as number | null) ?? null,
sonKilavuzYili: (row.sonKilavuzYili as number | null) ?? null,
sonYil,
sonSira: deger("s"),
sonPuan: deger("p"),
sonKontenjan: deger("k"),
sonYerlesen: deger("y"),
eskiKod: (row.eskiKod as string | null) ?? null,
};
}
function inKosulu(kolon: string, degerler: string[]): string {
return degerler.length ? `${kolon} IN (${degerler.map(() => "?").join(",")})` : "0 = 1";
}
/**
* Arşivdeki (güncel kılavuzda olmayan) programları arar: bölüm adı parçası,
* üniversite adı parçası (katlanmış), il (DB biçimi). En yeni son kılavuz yılı
* önce. Tablo yoksa boş.
*/
export function arsivProgramAra(
a: { arama?: string; universite?: string; il?: string; limit?: number }
): { toplam: number; programlar: ArsivProgram[] } {
if (!arsivTablosuVarMi()) return { toplam: 0, programlar: [] };
const kosullar: string[] = [];
const args: (string | number)[] = [];
if (a.arama) {
const adlar = katliEslesenler("isim", turkishSlugify(a.arama), "programs_arsiv");
kosullar.push(inKosulu("isim", adlar));
args.push(...adlar);
}
if (a.universite) {
const adlar = katliEslesenler("universite", turkishSlugify(a.universite), "programs_arsiv");
kosullar.push(inKosulu("universite", adlar));
args.push(...adlar);
}
if (a.il) {
kosullar.push("il = ?");
args.push(a.il);
}
if (kosullar.length === 0) return { toplam: 0, programlar: [] };
const where = `WHERE ${kosullar.join(" AND ")}`;
const db = getDb();
const toplam = (db.prepare(`SELECT COUNT(*) AS n FROM programs_arsiv ${where}`).get(...args) as { n: number }).n;
const satirlar = db
.prepare(
`SELECT ${ARSIV_SELECT} FROM programs_arsiv ${where}
ORDER BY son_kilavuz_yili DESC, universite, isim LIMIT ?`
)
.all(...args, a.limit ?? 10) as Record<string, unknown>[];
return { toplam, programlar: satirlar.map(arsivSatiri) };
}
/** Arşivdeki tek program (id `kod` ya da `kod~yıl` biçiminde olabilir). */
export function arsivProgramGetir(id: string): ArsivProgram | null {
if (!arsivTablosuVarMi()) return null;
const row = getDb()
.prepare(`SELECT ${ARSIV_SELECT} FROM programs_arsiv WHERE id = ? OR id LIKE ? ORDER BY son_kilavuz_yili DESC LIMIT 1`)
.get(id, `${id}~%`) as Record<string, unknown> | undefined;
return row ? arsivSatiri(row) : null;
}
/**
* Kayıtlı manuel liste satırlarının güncel efektif sırası (son yıl, yoksa bir
* önceki). Kayıtlar efektif sırayı ekleme anının snapshot'ı olarak taşır; yeni
* yerleştirme yılı yüklenince bununla tazelenir. Güncel kılavuzda olmayan
* (arşivlenmiş) id'ler sonuçta yer almaz — çağıran taraf eski değeri korur.
*/
export function efektifSiralarGetir(
ids: readonly string[]
): Record<string, number | null> {
if (ids.length === 0) return {};
const satirlar = getDb()
.prepare(
`SELECT id, ${EFEKTIF_SIRA} AS efektif FROM programs
WHERE id IN (${ids.map(() => "?").join(",")})`
)
.all(...ids) as { id: string; efektif: number | null }[];
const sonuc: Record<string, number | null> = {};
for (const s of satirlar) sonuc[s.id] = s.efektif;
return sonuc;
}
// Sihirbaz bubble'ları için erişilebilir program penceresi: hayal için
// gerçekçi alt sınırdan (sira*0.5) itibaren AÇIK uçlu — garanti tarafı artık
// sınırsız olduğundan üst kesim yok. Böylece 500B sıradaki aday Tıp bubble'ı
// görmez; 5B sıradaki görür.
//
// Kademeli (cascade) filtre: adımlar birbirini süzer. Kategoriler yalnızca
// pencereden; iller seçili kategorilerden; üniversite tipi kategori+il'den
// hesaplanır. Böylece "Mühendislik + İzmir" seçen aday 3. adımda yalnızca
// İzmir'deki mühendislik programlarının devlet/vakıf dağılımını görür.
export function rankWindowFacets(
sira: number,
turKey: PuanTuruKey,
secim: { kategoriler?: string[]; iller?: string[] } = {}
): SihirbazFacetleri {
const db = getDb();
const tur = PUAN_TURLERI[turKey];
const onlisans = turKey === "tyt" ? 1 : 0;
const altSinir = Math.round(sira * 0.5);
const satirlar = db
.prepare(
`SELECT isim, fakulte, il, unitur FROM programs
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${EFEKTIF_SIRA} >= ?`
)
.all(tur, onlisans, altSinir) as {
isim: string;
fakulte: string | null;
il: string | null;
unitur: string | null;
}[];
// isim+fakülte → kategori eşlemesi pahalı (regexler); tekrar edenler için önbellek
const kategoriCache = new Map<string, string[]>();
const kategorileriGetir = (
isim: string,
fakulte: string | null
): string[] => {
const anahtar = `${isim}|${fakulte ?? ""}`;
let k = kategoriCache.get(anahtar);
if (!k) {
k = kategoriBul(isim, fakulte);
kategoriCache.set(anahtar, k);
}
return k;
};
const seciliKategoriler = secim.kategoriler ?? [];
const kategoriUyar = (isim: string, fakulte: string | null) =>
seciliKategoriler.length === 0 ||
kategorileriGetir(isim, fakulte).some((k) =>
seciliKategoriler.includes(k)
);
const seciliIller = new Set(secim.iller ?? []);
const ilUyar = (il: string | null) =>
seciliIller.size === 0 || (il != null && seciliIller.has(il));
const kategoriSay = new Map<string, number>();
const ilSay = new Map<string, number>();
let devlet = 0;
let vakif = 0;
for (const { isim, fakulte, il, unitur } of satirlar) {
// 1. adım: kategoriler yalnızca pencereye bağlı (filtre uygulanmaz)
for (const k of kategorileriGetir(isim, fakulte)) {
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + 1);
}
if (!kategoriUyar(isim, fakulte)) continue;
// 2. adım: iller seçili kategorilerle süzülür
if (il) ilSay.set(il, (ilSay.get(il) ?? 0) + 1);
if (!ilUyar(il)) continue;
// 3. adım: üniversite tipi kategori + il ile süzülür
if (unitur === "DEVLET") devlet += 1;
else if (unitur) vakif += 1;
}
const kategoriler = KATEGORILER.map((ad) => ({
ad,
adet: kategoriSay.get(ad) ?? 0,
}))
.filter((k) => k.adet >= 3) // gürültüyü ele
.sort((a, b) => b.adet - a.adet);
// Penceredeki programı olan TÜM iller döner (81 il + KKTC/yurtdışı);
// sıralama/gruplama UI'da yapılır. Kırpma yok: kullanıcı her ili görebilmeli.
const iller = [...ilSay.entries()]
.map(([il, adet]) => ({ il, adet }))
.sort((a, b) => b.adet - a.adet);
const uniturler: { grup: UniturGrubu; adet: number }[] = [];
if (devlet > 0) uniturler.push({ grup: "devlet", adet: devlet });
if (vakif > 0) uniturler.push({ grup: "vakif", adet: vakif });
return { kategoriler, iller, uniturler, toplam: satirlar.length };
}