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>
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
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;
|
||
}
|