fix: order new tenders by validated publication time

This commit is contained in:
2026-09-06 09:20:45 +03:00
parent f593d5e372
commit efec6e434d
4 changed files with 54 additions and 17 deletions
+7
View File
@@ -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.
+5 -4
View File
@@ -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"
>
<option value="uyum">Şirketime En Uyumlu</option>
<option value="newest">En Yeni Eklenenler</option>
<option value="newest">Yayın Tarihi: En Yeni</option>
<option value="urgent">Tarihi Yaklaşanlar (Acil)</option>
<option value="distant">Tarihi Uzak Olanlar</option>
</select>
@@ -179,6 +179,7 @@ export const TenderList = () => {
</div>
</div>
{sortBy === 'newest' && <p className="text-xs text-slate-500">Yayın tarihi bilinmeyen kayıtlar sonda gösterilir. Aynı gün içinde saati bilinen kayıtlar önce gelir.</p>}
{exportError && <p role="alert" className="text-sm text-red-700">{exportError}</p>}
{/* Tender Cards List */}
{gorunenTenders.length === 0 ? (
+31 -12
View File
@@ -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) => {
+11 -1
View File
@@ -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'},