Files
kolaytercih/src/lib/katalog.ts
bilalgursen ce40b7c5b3 feat(veri): Program tipi yılsız — db eşleyicisi, lib ve snapshot ilkYil (P-B a)
Program artık siraGecmisi (YILLAR ile hizalı) + efektifSira + siraSon/puanSon/
kontenjanSon/yerlesenSon taşır. db.ts kolonları YILLAR'dan üretip takma adla
seçer, programSatiri eşleyicisi ham satırı Program'a çevirir; SELECT_COLS'un
üç tüketicisi de eşleyiciden geçer. EFEKTIF_SIRA veri-yillari'dan gelir.

- trend: ILK_YIL tek kaynaktan, opsiyonel ilkYil parametresi
- rapor snapshot'ına opsiyonel ilkYil yazılır (eski kayıtlar: okurken ILK_YIL)
- rapor promptu son 4 yılı göndermeye devam eder, anahtarlar dinamik
- tadımlık "son N yıl" filtresi genel: N != VERI_YIL_SAYISI ise servis edilmez
- veri-kapsami VERI_YIL_SAYISI'nı veri-yillari'dan türetir

Bileşenler bir sonraki commit'te yeni tipe geçer (ikisi birlikte derlenir).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 17:00:39 +03:00

420 lines
13 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.
import "server-only";
import {
getDb,
programSatiri,
SELECT_COLS,
EFEKTIF_SIRA,
type ProgramHamSatiri,
} from "./db";
import type { Program } from "@/types/yokatlas";
import { kategoriBul, type Kategori } from "./kategoriler";
import { uniAdiNormalize } from "./harita";
import { bolumBazAdi, turkishSlugify, trBaslikDuzeni } from "./slug";
/**
* Programatik SEO sayfalarının veri katmanı (/bolum, /universite, hub'lar).
*
* Slug haritaları modül-scope'ta lazy kurulur: DB readonly açıldığı için
* slugs tablosu yazılamaz; SELECT DISTINCT (1.5K satır) süreç başına bir kez
* <10ms sürer. Harita değerleri HAM isim/universite dizeleridir — sorgular
* `WHERE isim IN (?, ...)` ile birebir eşleşip index'e vurur (WHERE içinde
* TRIM/fonksiyon kullanmak index'i öldürür).
*/
// ---------- Bölüm tarafı ----------
export type BolumOzet = {
slug: string;
ad: string;
programSayisi: number;
kategoriler: Kategori[];
lisans: boolean;
onlisans: boolean;
/** MIN(efektif sıra) — en iyi (en küçük) taban sıralaması */
enIyiSira: number | null;
};
export type BolumStats = {
toplam: number;
devlet: number;
vakif: number;
/** unitur boş/NULL olan satırlar — vakıf SAYILMAZ, ayrı gösterilir */
bilinmeyenTur: number;
minSira: number | null;
maxSira: number | null;
minPuan: number | null;
maxPuan: number | null;
ilSayisi: number;
};
export type BolumDetay = BolumOzet & {
lisansProgramlari: Program[];
onlisansProgramlari: Program[];
stats: BolumStats;
};
type BolumKayit = { slug: string; ad: string; hamIsimler: string[] };
const globalKatalog = globalThis as unknown as {
bolumMap?: Map<string, BolumKayit>;
bolumOzetler?: BolumOzet[];
uniMap?: Map<string, UniKayit>;
uniOzetler?: UniOzet[];
};
function bolumMapGetir(): Map<string, BolumKayit> {
if (!globalKatalog.bolumMap) {
const rows = getDb()
.prepare("SELECT DISTINCT isim FROM programs")
.all() as { isim: string }[];
const map = new Map<string, BolumKayit>();
for (const { isim } of rows) {
const baz = bolumBazAdi(isim);
if (!baz) continue;
const slug = turkishSlugify(baz);
if (!slug) continue;
const kayit = map.get(slug);
if (kayit) kayit.hamIsimler.push(isim);
else map.set(slug, { slug, ad: baz, hamIsimler: [isim] });
}
if (map.size === 0) {
// generateStaticParams boş dizi dönerse build hatası — boş DB'yi erken yakala
throw new Error("katalog: programs tablosu boş görünüyor");
}
globalKatalog.bolumMap = map;
}
return globalKatalog.bolumMap;
}
export function getAllBolumler(): BolumOzet[] {
if (!globalKatalog.bolumOzetler) {
const map = bolumMapGetir();
// Tek taramada tüm bölümlerin özeti: 23K satır, süreç başına bir kez
const rows = getDb()
.prepare(
`SELECT isim, onlisans, ${EFEKTIF_SIRA} AS efektif FROM programs`,
)
.all() as { isim: string; onlisans: number; efektif: number | null }[];
const ozet = new Map<string, BolumOzet>();
const slugByIsim = new Map<string, string>();
for (const kayit of map.values()) {
for (const ham of kayit.hamIsimler) slugByIsim.set(ham, kayit.slug);
ozet.set(kayit.slug, {
slug: kayit.slug,
ad: kayit.ad,
programSayisi: 0,
kategoriler: kategoriBul(kayit.ad),
lisans: false,
onlisans: false,
enIyiSira: null,
});
}
for (const row of rows) {
const slug = slugByIsim.get(row.isim);
if (!slug) continue;
const b = ozet.get(slug)!;
b.programSayisi += 1;
if (row.onlisans === 1) b.onlisans = true;
else b.lisans = true;
if (row.efektif != null && (b.enIyiSira == null || row.efektif < b.enIyiSira)) {
b.enIyiSira = row.efektif;
}
}
globalKatalog.bolumOzetler = [...ozet.values()].sort((a, b) =>
a.ad.localeCompare(b.ad, "tr-TR"),
);
}
return globalKatalog.bolumOzetler;
}
export function getAllBolumSlugs(): string[] {
return [...bolumMapGetir().keys()];
}
export function getBolumBySlug(slug: string): BolumDetay | null {
const kayit = bolumMapGetir().get(slug);
if (!kayit) return null;
const placeholders = kayit.hamIsimler.map(() => "?").join(",");
const programlar = (
getDb()
.prepare(
`SELECT ${SELECT_COLS} FROM programs
WHERE isim IN (${placeholders})
ORDER BY (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`,
)
.all(...kayit.hamIsimler) as ProgramHamSatiri[]
).map(programSatiri);
// onlisans SELECT_COLS içinde yok; tur üzerinden ayır (TYT = önlisans)
const lisansProgramlari = programlar.filter((p) => p.tur !== "TYT");
const onlisansProgramlari = programlar.filter((p) => p.tur === "TYT");
const iller = new Set<string>();
let devlet = 0;
let vakif = 0;
let bilinmeyenTur = 0;
let minSira: number | null = null;
let maxSira: number | null = null;
let minPuan: number | null = null;
let maxPuan: number | null = null;
for (const p of programlar) {
if (p.il) iller.add(p.il);
if (p.unitur === "DEVLET") devlet += 1;
else if (p.unitur) vakif += 1;
else bilinmeyenTur += 1;
const efektif = p.efektifSira;
if (efektif != null) {
if (minSira == null || efektif < minSira) minSira = efektif;
if (maxSira == null || efektif > maxSira) maxSira = efektif;
}
if (p.puanSon != null) {
if (minPuan == null || p.puanSon < minPuan) minPuan = p.puanSon;
if (maxPuan == null || p.puanSon > maxPuan) maxPuan = p.puanSon;
}
}
const ozet = getAllBolumler().find((b) => b.slug === slug)!;
return {
...ozet,
lisansProgramlari,
onlisansProgramlari,
stats: {
toplam: programlar.length,
devlet,
vakif,
bilinmeyenTur,
minSira,
maxSira,
minPuan,
maxPuan,
ilSayisi: iller.size,
},
};
}
/** Aynı kategorideki diğer bölümler, program sayısına göre (kendisi hariç). */
export function ilgiliBolumler(slug: string, limit = 8): BolumOzet[] {
const hepsi = getAllBolumler();
const kendi = hepsi.find((b) => b.slug === slug);
if (!kendi) return [];
const kendiKategoriler = new Set<string>(kendi.kategoriler);
return hepsi
.filter(
(b) =>
b.slug !== slug &&
(kendiKategoriler.size === 0 ||
b.kategoriler.some((k) => kendiKategoriler.has(k))),
)
.sort((a, b) => b.programSayisi - a.programSayisi)
.slice(0, limit);
}
// ---------- Üniversite tarafı ----------
export type UniOzet = {
slug: string;
/** tr-TR başlık düzeninde görünen ad (İstanbul Teknik Üniversitesi) */
ad: string;
il: string | null;
unitur: string | null;
programSayisi: number;
fakulteSayisi: number;
};
export type UniDetay = UniOzet & {
fakulteler: { fakulte: string; programlar: Program[] }[];
stats: {
minSira: number | null;
toplamKontenjan: number;
devletMi: boolean;
};
};
type UniKayit = { slug: string; ad: string; hamAdlar: string[] };
function uniMapGetir(): Map<string, UniKayit> {
if (!globalKatalog.uniMap) {
const rows = getDb()
.prepare("SELECT DISTINCT universite FROM programs")
.all() as { universite: string }[];
const map = new Map<string, UniKayit>();
for (const { universite } of rows) {
const normal = uniAdiNormalize(universite);
if (!normal) continue;
const slug = turkishSlugify(normal);
if (!slug) continue;
const kayit = map.get(slug);
if (kayit) kayit.hamAdlar.push(universite);
else
map.set(slug, {
slug,
ad: trBaslikDuzeni(normal),
hamAdlar: [universite],
});
}
if (map.size === 0) {
throw new Error("katalog: programs tablosunda üniversite yok");
}
globalKatalog.uniMap = map;
}
return globalKatalog.uniMap;
}
export function getAllUniversiteler(): UniOzet[] {
if (!globalKatalog.uniOzetler) {
const map = uniMapGetir();
const rows = getDb()
.prepare(
`SELECT universite, MAX(unitur) AS unitur, COUNT(*) AS adet
FROM programs GROUP BY universite`,
)
.all() as {
universite: string;
unitur: string | null;
adet: number;
}[];
// il: MAX(il) alfabetik en büyüğü seçip yanıltıyordu (ör. Ankara Sosyal
// Bilimler'in 12 KKTC satırı 47 Ankara satırını eziyordu); üniversitenin
// en çok programının bulunduğu il esas alınır.
const ilRows = getDb()
.prepare(
`SELECT universite, il, COUNT(*) AS adet FROM programs
WHERE il IS NOT NULL GROUP BY universite, il`,
)
.all() as { universite: string; il: string; adet: number }[];
// Fakülte sayısı: aynı üniversitenin iki yazımı ("X ÜNİV." / "X ÜNİV. (İL)")
// birleştiği için GROUP BY universite üzerinden saymak çift sayardı;
// (slug, fakulte) çifti üzerinden kesin sayılır.
const fakulteRows = getDb()
.prepare(
"SELECT DISTINCT universite, fakulte FROM programs WHERE fakulte IS NOT NULL",
)
.all() as { universite: string; fakulte: string }[];
const bySlug = new Map<string, UniOzet>();
const slugByHam = new Map<string, string>();
for (const kayit of map.values()) {
for (const ham of kayit.hamAdlar) slugByHam.set(ham, kayit.slug);
bySlug.set(kayit.slug, {
slug: kayit.slug,
ad: kayit.ad,
il: null,
unitur: null,
programSayisi: 0,
fakulteSayisi: 0,
});
}
for (const row of rows) {
const slug = slugByHam.get(row.universite);
if (!slug) continue;
const u = bySlug.get(slug)!;
u.programSayisi += row.adet;
if (!u.unitur && row.unitur) u.unitur = row.unitur;
}
const ilAdetleri = new Map<string, Map<string, number>>();
for (const row of ilRows) {
const slug = slugByHam.get(row.universite);
if (!slug) continue;
let sayac = ilAdetleri.get(slug);
if (!sayac) {
sayac = new Map();
ilAdetleri.set(slug, sayac);
}
sayac.set(row.il, (sayac.get(row.il) ?? 0) + row.adet);
}
for (const [slug, sayac] of ilAdetleri) {
let enIyi: string | null = null;
let enCok = 0;
for (const [il, adet] of sayac) {
if (adet > enCok) {
enIyi = il;
enCok = adet;
}
}
bySlug.get(slug)!.il = enIyi;
}
const fakulteSetleri = new Map<string, Set<string>>();
for (const row of fakulteRows) {
const slug = slugByHam.get(row.universite);
if (!slug) continue;
let set = fakulteSetleri.get(slug);
if (!set) {
set = new Set();
fakulteSetleri.set(slug, set);
}
set.add(row.fakulte);
}
for (const [slug, set] of fakulteSetleri) {
bySlug.get(slug)!.fakulteSayisi = set.size;
}
globalKatalog.uniOzetler = [...bySlug.values()].sort((a, b) =>
a.ad.localeCompare(b.ad, "tr-TR"),
);
}
return globalKatalog.uniOzetler;
}
export function getAllUniversiteSlugs(): string[] {
return [...uniMapGetir().keys()];
}
export function getUniversiteBySlug(slug: string): UniDetay | null {
const kayit = uniMapGetir().get(slug);
if (!kayit) return null;
const placeholders = kayit.hamAdlar.map(() => "?").join(",");
const programlar = (
getDb()
.prepare(
`SELECT ${SELECT_COLS} FROM programs
WHERE universite IN (${placeholders})
ORDER BY fakulte, (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`,
)
.all(...kayit.hamAdlar) as ProgramHamSatiri[]
).map(programSatiri);
const gruplar = new Map<string, Program[]>();
let minSira: number | null = null;
let toplamKontenjan = 0;
for (const p of programlar) {
const anahtar = p.fakulte ?? "Diğer";
const liste = gruplar.get(anahtar);
if (liste) liste.push(p);
else gruplar.set(anahtar, [p]);
const efektif = p.efektifSira;
if (efektif != null && (minSira == null || efektif < minSira)) {
minSira = efektif;
}
toplamKontenjan += p.kontenjanSon ?? 0;
}
const ozet = getAllUniversiteler().find((u) => u.slug === slug)!;
return {
...ozet,
fakulteler: [...gruplar.entries()]
.map(([fakulte, programlar]) => ({ fakulte, programlar }))
.sort((a, b) => a.fakulte.localeCompare(b.fakulte, "tr-TR")),
stats: {
minSira,
toplamKontenjan,
devletMi: ozet.unitur === "DEVLET",
},
};
}
// ---------- Yardımcılar ----------
/** Ham program isminden /bolum sayfası slug'ı (tablolarda çapraz link için). */
export function bolumSlugFromIsim(isim: string): string | null {
const slug = turkishSlugify(bolumBazAdi(isim));
return bolumMapGetir().has(slug) ? slug : null;
}
/** Ham üniversite adından /universite sayfası slug'ı. */
export function uniSlugFromAd(universite: string): string | null {
const slug = turkishSlugify(uniAdiNormalize(universite));
return uniMapGetir().has(slug) ? slug : null;
}