diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 71192dd..19f69d1 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -228,3 +228,10 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont - Türkçe metin, virgül, satır sonu, #, & ve İKN metin olarak korunuyor. Kaynak metni Excel formülüne dönüştürülmüyor. Başlık sabitleme ve otomatik filtre var. - Çıktı hazırlanırken düğme bekleme gösteriyor; hata görünür. Ekran kapatılır veya oturum değişirse geç gelen dosya indirmesi iptal ediliyor. Boş liste dışa aktarımı engelleniyor. - Kanıt: 125 satırın gerçek XLSX yeniden okunması, ilk/son kayıt, saat, CPV, kaynak ve = ile başlayan metin hücresi testi geçti. 62/62 test ve build geçti. Tarayıcıdan dosya indirme ve masaüstü Excel açılışı bu adımda yapılmadı; tüm çıktı kabulü henüz tamamlanmış sayılmıyor. + +### D05 — Yayın zamanına göre yeni ilan sıralaması + +- “En yeni” artık kayıt kimliğine göre değil doğrulanabilir yayın tarihi/zamanına göre sıralanıyor. Alan etiketi “Yayın Tarihi: En Yeni” olarak açıklandı. Tarihi bilinmeyenler sonda; aynı günde saati bilinmeyenler kesin saatli kayıtların ardından geliyor. +- ISO/ofsetli zamanlar Türkiye gününe dönüştürülüyor; Türkçe kaynaklardaki ofsetsiz saatler İstanbul kabul ediliyor. Geçersiz takvim günü/saat reddediliyor; varsa ayrı ilan tarihi kullanılıyor. Süre metni veya keyfi kayıt kimliği yayın zamanı olarak yorumlanmıyor. +- Bugün eklenenler hesabında bilinen yayın tarihi, eskimiş isYeni bayrağına üstün geliyor. Uyum puanı eşitliğinde de kimlik yerine yayın sırası kullanılıyor; sayısal id nedeniyle localeCompare hatası ortadan kalktı. +- Kanıt: yayın zamanı regresyonuyla 63/63 test ve build geçti. UTC/Türkiye gün sınırı, ofsetsiz saat, farklı türde kimlik, geçersiz tarih ve eski isYeni bayrağı doğrulandı. Kaynakların gerçek saat dilimi sözleşmelerinin tek tek teyidi ve gerçek veriyle görsel kabul hâlâ açık. diff --git a/src/components/TenderList.jsx b/src/components/TenderList.jsx index 02c4dd2..1f6f803 100644 --- a/src/components/TenderList.jsx +++ b/src/components/TenderList.jsx @@ -5,7 +5,7 @@ import { Download, SlidersHorizontal, Layers, Loader2 } from 'lucide-react'; import { createTenderWorkbook } from '../services/tenderWorkbook'; import { getToken } from '../services/auth'; import { useDeadlineClock } from '../hooks/useDeadlineClock'; -import { acikIhale, hizliFiltreGecer, GECMIS_FILTRELER, compareTenderDeadlines } from '../services/ihaleTarih'; +import { acikIhale, hizliFiltreGecer, GECMIS_FILTRELER, compareTenderDeadlines, compareTenderPublications } from '../services/ihaleTarih'; // İl adları kaynaklar arasında farklı yazılabiliyor (Ankara / ANKARA); // filtre büyük-küçük harf ve Türkçe karakter duyarlılığına takılmasın. @@ -72,8 +72,8 @@ export const TenderList = () => { return true; }).sort((a, b) => { - if (sortBy === 'uyum') return (b.aiMatchScore ?? -1) - (a.aiMatchScore ?? -1) || b.id.localeCompare(a.id); - if (sortBy === 'newest') return b.id.localeCompare(a.id); + if (sortBy === 'uyum') return (b.aiMatchScore ?? -1) - (a.aiMatchScore ?? -1) || compareTenderPublications(a,b); + if (sortBy === 'newest') return compareTenderPublications(a,b); if (sortBy === 'urgent') return compareTenderDeadlines(a,b); if (sortBy === 'distant') return compareTenderDeadlines(a,b,-1); return 0; @@ -164,7 +164,7 @@ export const TenderList = () => { className="bg-slate-50 border border-slate-300 rounded-lg px-3 py-1.5 text-xs font-semibold text-slate-700 focus:ring-2 focus:ring-blue-500" > - + @@ -179,6 +179,7 @@ export const TenderList = () => { + {sortBy === 'newest' &&

Yayın tarihi bilinmeyen kayıtlar sonda gösterilir. Aynı gün içinde saati bilinen kayıtlar önce gelir.

} {exportError &&

{exportError}

} {/* Tender Cards List */} {gorunenTenders.length === 0 ? ( diff --git a/src/services/ihaleTarih.js b/src/services/ihaleTarih.js index e76914a..7af6b98 100644 --- a/src/services/ihaleTarih.js +++ b/src/services/ihaleTarih.js @@ -16,22 +16,41 @@ export const ihaleGunu = (v) => { return key ? new Date(key+'T00:00:00+03:00') : null; }; -// Her biçimi "yyyy-aa-gg" dizesine indirger (karşılaştırma için). -export const ilanTarihiISO = (t) => { - const v = String(t?.ilanTarihi || '').trim(); - let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(v); - if (m) return `${m[1]}-${m[2]}-${m[3]}`; - m = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(v); - if (m) return `${m[3]}-${m[2]}-${m[1]}`; - return ''; -}; +// Publication metadata is distinct from the tender deadline. +export function publicationInfo(t) { + for (const value of [t?.yayinZamani,t?.ilanTarihi]) { + const v=String(value || '').trim(); + const m=/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,7}))?)?(Z|[+-]\d{2}:\d{2})?)?$/.exec(v); + const tr=/^(\d{2})\.(\d{2})\.(\d{4})$/.exec(v); + const key=m ? tenderDateKey({tarih:`${m[3]}.${m[2]}.${m[1]}`}) : tr ? tenderDateKey({tarih:v}) : null; + if (!key) continue; + if (!m?.[4]) return {dateKey:key,timestamp:null}; + if (+m[4]>23 || +m[5]>59 || +(m[6] || 0)>59) continue; + // Offset-free Turkish source timestamps use Istanbul, never the browser timezone. + const stamp=Date.parse(`${key}T${m[4]}:${m[5]}:${m[6] || '00'}${m[7] ? '.'+m[7].slice(0,3) : ''}${m[8] || '+03:00'}`); + if (Number.isFinite(stamp)) return {dateKey:istanbulDateKey(new Date(stamp)),timestamp:stamp}; + } + return {dateKey:null,timestamp:null}; +} +export const ilanTarihiISO = t => publicationInfo(t).dateKey || ''; +export function compareTenderPublications(a,b) { + const x=publicationInfo(a),y=publicationInfo(b); + if (!x.dateKey || !y.dateKey) return x.dateKey ? -1 : y.dateKey ? 1 : 0; + const day=y.dateKey.localeCompare(x.dateKey); + if (day) return day; + if (x.timestamp === null || y.timestamp === null) return x.timestamp !== null ? -1 : y.timestamp !== null ? 1 : 0; + return y.timestamp-x.timestamp; +} const iso = istanbulDateKey; -export const bugunISO = () => iso(new Date()); +export const bugunISO = (now = new Date()) => iso(now); -// Bugün yayımlanan ilan. Sunucunun isYeni bayrağı esas; o yoksa ilan tarihine bakılır. -export const bugunEklendi = (t) => Boolean(t?.isYeni) || (ilanTarihiISO(t) !== '' && ilanTarihiISO(t) === bugunISO()); +// Bugün yayımlanan ilan. Bilinen yayın tarihi esas; yalnız tarih yoksa kaynak bayrağı kullanılır. +export const bugunEklendi = (t, now = new Date()) => { + const day=ilanTarihiISO(t); + return day ? day === bugunISO(now) : Boolean(t?.isYeni); +}; // Önceki ziyaretten (ms) sonra yayımlanan ilan. Kayıt yoksa "bugün" kabul edilir. export const girisSonrasiEklendi = (t, oncekiGirisMs) => { diff --git a/tests/list-dates.test.mjs b/tests/list-dates.test.mjs index 12cac32..c870f1d 100644 --- a/tests/list-dates.test.mjs +++ b/tests/list-dates.test.mjs @@ -1,6 +1,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { acikIhale, compareTenderDeadlines, ihaleGunu } from '../src/services/ihaleTarih.js'; +import { acikIhale, compareTenderDeadlines, ihaleGunu, compareTenderPublications, publicationInfo, bugunEklendi } from '../src/services/ihaleTarih.js'; +test('publication sorting ignores arbitrary ids and handles Istanbul day boundaries',()=>{ + const rows=[{id:'zzz',ilanTarihi:'05.09.2026'}, {id:1,yayinZamani:'2026-09-05T22:00:00Z'}, + {id:'aaa',yayinZamani:'2026-09-06T09:00:00'}, {id:'unknown'}]; + assert.deepEqual([...rows].sort(compareTenderPublications).map(t=>t.id),['aaa',1,'zzz','unknown']); + assert.deepEqual(publicationInfo(rows[1]),{dateKey:'2026-09-06',timestamp:Date.parse('2026-09-05T22:00:00Z')}); + assert.equal(publicationInfo(rows[2]).timestamp,Date.parse('2026-09-06T06:00:00Z')); + assert.equal(publicationInfo({ilanTarihi:'2026-02-31'}).dateKey,null); + assert.equal(publicationInfo({yayinZamani:'2026-09-06T25:00:00',ilanTarihi:'05.09.2026'}).dateKey,'2026-09-05'); + assert.equal(bugunEklendi({ilanTarihi:'05.09.2026',isYeni:true},new Date('2026-09-06T09:00:00Z')),false); +}); test('list date sorting crosses month/year boundaries and leaves unknown dates last',()=>{ const rows=[ {id:'oct',tarih:'01.10.2026',saat:'09:00'},