diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md
index b994026..245e8bb 100644
--- a/docs/quality/uygulama-durumu.md
+++ b/docs/quality/uygulama-durumu.md
@@ -162,3 +162,10 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont
- Not alanına erişilebilir ad eklendi, kayıt beklerken/boş metinde gönderim düğmesi devre dışı bırakıldı. Kullanılmayan eski kaşe sürükleme durumu kaldırıldı.
- Doğrulama: 48/48 otomatik test ve üretim derlemesi geçti. İki yeni test gecikmiş yanıt, yeni düzenleme, aynı metne geri dönme, başka ihale ve hesap/oturum ayrımını kapsıyor. Gerçek tarayıcıda geciktirilmiş kayıt senaryosu bu adımda çalıştırılmadı. Mevcut paket boyutu uyarıları sürüyor.
- Tam plan, resmi beş dosya kabulü ve canlı yayın hâlâ tamamlanmış sayılmıyor.
+
+### D02 / D09 — Sunucu tarih hesabı ve takip tüketicileri
+
+- RSS/MCP açık ihale filtresi ve yaklaşan ihale API’si arayüzle aynı `deadlineInfo` hesabını kullanıyor. Bugün saati geçmiş kayıt yaklaşan listeden çıkarılıyor; bilinmeyen saat `saat_dogrulanmali` ile ayrılıyor. Geçersiz tarih yaklaşan listeye alınmıyor; genel aramada kaydı gizlemek yerine korunuyor.
+- Yaklaşan liste, haftalık özet, zeyilname takip haritası ve plan takip sayacı güncel tender_state kayıtlarını okuyor. Eski takip listesi uyumluluk için okunuyor ancak güncel açık takipten çıkarma kararı eski kopyaya üstün geliyor. Havuz dışında kalan takipli kayıtlar saklanan anlık görüntüden yaklaşan listeye katılıyor.
+- Haftalık özetin HTML/metin çıktısında saat ve Türkiye saati etiketi veya saat doğrulama uyarısı var. Pazartesi 08.00 zamanlama hesabı sunucu yerel saatinden bağımsız. İlan aciliyet günü de ortak tarih hesabını kullanıyor.
+- Kanıt: 51/51 otomatik test geçti. Yeni testler üç sunucu saat dilimi, İstanbul/UTC gün sınırı, geçersiz tarih, geçmiş saat, eksik saat, takipten çıkarma, havuz dışı kayıt, hesap ayrımı ve haftalık çalışma sınırını doğruluyor. Gerçek e-posta/Telegram/push gönderilmedi; teslim ve yeniden deneme senaryoları hâlâ açık. Resmi takvim içe aktarımı ve tüm zeyilname akışı tamamlanmış sayılmıyor.
diff --git a/server/brief.mjs b/server/brief.mjs
index 9f10bdd..f451c49 100644
--- a/server/brief.mjs
+++ b/server/brief.mjs
@@ -25,7 +25,7 @@ const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-T
/** Bir kullanıcı için brief içeriği. */
export function briefOlustur(email, tenders) {
- const takip = (store.getData(email, 'takip') || []).map(String);
+ const takip = store.getTrackedTenderKeys(email);
const yaklasan = yaklasanIhaleler(email, tenders, 7);
const birHafta = Date.now() - 7 * 86400000;
const sonuclar = [];
@@ -40,11 +40,15 @@ export function briefOlustur(email, tenders) {
return { hafta, yaklasan, sonuclar: sonuclar.slice(0, 15), bos: !yaklasan.length && !sonuclar.length };
}
+const deadlineText = t => t.son_teklif_zamani
+ ? new Date(t.son_teklif_zamani).toLocaleTimeString('tr-TR', { timeZone: 'Europe/Istanbul', hour: '2-digit', minute: '2-digit' }) + ' (Türkiye saati)'
+ : 'saat doğrulanmalı';
+
export function briefMetni(b) {
const s = [`📋 Haftalık İhale Brief — ${b.hafta}`];
if (b.yaklasan.length) {
s.push('', `⏰ Bu hafta yapılacak takipli ihaleler (${b.yaklasan.length}):`);
- for (const t of b.yaklasan) s.push(`• ${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${t.baslik} (${t.kurum}, ${t.tarih}${t.saat ? ' ' + t.saat : ''})`);
+ for (const t of b.yaklasan) s.push(`• ${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${t.baslik} (${t.kurum}, ${t.tarih} · ${deadlineText(t)})`);
}
if (b.sonuclar.length) {
s.push('', `🏁 Geçen hafta sonuçlanan takipliler (${b.sonuclar.length}):`);
@@ -60,7 +64,7 @@ export function briefHtml(b) {
? `
${baslik}
${satirlar.map((x) => `- ${x}
`).join('')}
` : '';
return `
📋 Haftalık İhale Brief
${esc(b.hafta)}
- ${bolum('⏰ Bu hafta yapılacak takipli ihaleler', b.yaklasan.map((t) => `
${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${esc(t.baslik)} (${esc(t.kurum)}, ${esc(t.tarih)})`))}
+ ${bolum('⏰ Bu hafta yapılacak takipli ihaleler', b.yaklasan.map((t) => `
${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${esc(t.baslik)} (${esc(t.kurum)}, ${esc(t.tarih)} · ${esc(deadlineText(t))})`))}
${bolum('🏁 Geçen hafta sonuçlanan takipliler', b.sonuclar.map((r) => `${esc(r.baslik)} → ${esc(r.kazanan || 'kazanan açıklanmadı')}${r.bedel != null ? ' · ' + esc(paraTR(r.bedel, r.birim)) : ''}`))}
Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.
`;
}
@@ -88,13 +92,18 @@ export async function haftalikBriefGonder(tenderGetir, { zorla = false } = {}) {
return r;
}
-/** Saatlik kontrol: Pazartesi ≥08:00 ve bu hafta gönderilmediyse gönder. */
+export function briefScheduleKey(now = new Date()) {
+ const tr = new Date(now.getTime() + 3 * 3600000);
+ if (tr.getUTCDay() !== 1 || tr.getUTCHours() < 8) return null;
+ return `${tr.getUTCFullYear()}-${tr.getUTCMonth()}-${tr.getUTCDate()}`;
+}
+
+/** Saatlik kontrol: Türkiye saatiyle Pazartesi ≥08:00. */
export function briefZamanlayici(tenderGetir) {
if (process.env.BRIEF_ENABLED === '0') { console.log('[brief] haftalık brief devre dışı (BRIEF_ENABLED=0)'); return; }
const kontrol = async () => {
- const simdi = new Date();
- if (simdi.getDay() !== 1 || simdi.getHours() < 8) return;
- const anahtar = `${simdi.getFullYear()}-${simdi.getMonth()}-${simdi.getDate()}`;
+ const anahtar = briefScheduleKey();
+ if (!anahtar) return;
const d = sonGonderim();
if (d.son === anahtar) return;
sonGonderimYaz({ son: anahtar });
diff --git a/server/entitlements.mjs b/server/entitlements.mjs
index ddbb8d3..aa2da27 100644
--- a/server/entitlements.mjs
+++ b/server/entitlements.mjs
@@ -4,7 +4,7 @@
* edilmiyordu — herkes her şeyi kullanabiliyordu. Burada gerçekten uygulanır.
*/
-import { getUser, documentUsage, listApiKeys, getData } from './user-store.mjs';
+import { getUser, documentUsage, listApiKeys, getData, getTrackedTenderKeys } from './user-store.mjs';
export const PLAN_HAKLARI = {
ucretsiz: {
@@ -61,7 +61,7 @@ export function kullanim(email, opts) {
const h = haklar(email, opts);
const belge = documentUsage(email);
const ekip = (getData(email, 'ekip') || []).length;
- const takip = (getData(email, 'kanban') || []).filter?.((t) => t?.takipte)?.length ?? null;
+ const takip = getTrackedTenderKeys(email).length;
// Yönetici hesapları her zaman tam erişimlidir; bu, satın alınan aboneliği
// GİZLEMEMELİ — arayüz ikisini ayrı gösterebilsin diye ikisini de döndürüyoruz.
const u = getUser(email);
diff --git a/server/feeds-mcp.mjs b/server/feeds-mcp.mjs
index 6f5ee70..83ac794 100644
--- a/server/feeds-mcp.mjs
+++ b/server/feeds-mcp.mjs
@@ -6,6 +6,8 @@
// Claude Desktop (mcp-remote) araçları: search_tenders, get_tender,
// search_results, search_items, upcoming_deadlines. Bağımlılık yok.
import * as store from './user-store.mjs';
+import { deadlineInfo, tenderDateKey } from '../src/services/tenderCalendar.js';
+import { applyTenderStates, tenderStateKey } from '../src/services/tenderState.js';
import { readHistory, resultsByIkn } from './history-store.mjs';
import * as kalemStore from './kalem-store.mjs';
@@ -25,17 +27,17 @@ export function rssUret({ title, link, description, items }) {
/** dd.mm.yyyy → Date (ihaleist tarih biçimi). */
export function trTarih(s) {
- const m = /(\d{1,2})[./](\d{1,2})[./](\d{4})/.exec(String(s ?? ''));
- return m ? new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])) : null;
+ const m = /^(\d{1,2})[./](\d{1,2})[./](\d{4})$/.exec(String(s ?? '').trim());
+ const key = m && tenderDateKey({ tarih: `${m[1].padStart(2, '0')}.${m[2].padStart(2, '0')}.${m[3]}` });
+ return key ? new Date(key + 'T00:00:00+03:00') : null;
}
/** Liste filtresi — RSS ve MCP ortak. */
-export function ihaleFiltrele(tenders, { q, il, sektor, sadeceAcik = true, limit = 50 } = {}) {
+export function ihaleFiltrele(tenders, { q, il, sektor, sadeceAcik = true, limit = 50 } = {}, now = new Date()) {
const qq = norm(q); const ill = norm(il); const sek = norm(sektor);
- const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
return tenders
.filter((t) => !sadeceAcik || !t.kazananFirma)
- .filter((t) => !sadeceAcik || !trTarih(t.tarih) || trTarih(t.tarih) >= bugun)
+ .filter((t) => !sadeceAcik || deadlineInfo(t, now).expired !== true)
.filter((t) => !ill || norm(t.sehir).includes(ill))
.filter((t) => !sek || norm(t.sektor).includes(sek))
.filter((t) => !qq || norm(t.baslik).includes(qq) || norm(t.kurum).includes(qq) || String(t.ikn ?? '').includes(qq) ||
@@ -99,16 +101,20 @@ export const TOOLS = [
const hata = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
const sonuc = (id, result) => ({ jsonrpc: '2.0', id: id ?? null, result });
-export function yaklasanIhaleler(email, tenders, gun = 14) {
- const takip = new Set((store.getData(email, 'takip') || []).map(String));
- const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
- const sinir = new Date(bugun.getTime() + gun * 86400000);
- return tenders
- .filter((t) => t.ikn && takip.has(String(t.ikn)))
- .map((t) => ({ t, d: trTarih(t.tarih) }))
- .filter(({ d }) => d && d >= bugun && d <= sinir)
- .sort((a, b) => a.d - b.d)
- .map(({ t, d }) => ({ ikn: t.ikn, baslik: t.baslik, kurum: t.kurum, sehir: t.sehir, tarih: t.tarih, saat: t.saat, kalan_gun: Math.round((d - bugun) / 86400000), kaynakUrl: t.kaynakUrl }));
+export function yaklasanIhaleler(email, tenders, gun = 14, now = new Date()) {
+ const states = store.getTenderStates(email);
+ const takip = new Set(store.getTrackedTenderKeys(email, states));
+ const days = Math.min(90, Math.max(1, Math.floor(Number(gun) || 14)));
+ return applyTenderStates(tenders, states)
+ .filter(t => !t.kazananFirma && takip.has(tenderStateKey(t)))
+ .map(t => ({ t, info: deadlineInfo(t, now) }))
+ .filter(({ info }) => info.days !== null && info.days >= 0 && info.days <= days && info.expired !== true)
+ .sort((a, b) => a.info.days - b.info.days ||
+ (a.info.deadline?.getTime() ?? Infinity) - (b.info.deadline?.getTime() ?? Infinity))
+ .map(({ t, info }) => ({ ikn: t.ikn, baslik: t.baslik, kurum: t.kurum, sehir: t.sehir,
+ tarih: t.tarih, saat: t.saat, kalan_gun: info.days,
+ son_teklif_zamani: info.deadline?.toISOString() ?? null,
+ saat_dogrulanmali: !info.deadline, kaynakUrl: t.kaynakUrl }));
}
export async function aracCalistir(name, args, user, ctx) {
diff --git a/server/index.js b/server/index.js
index 8993525..ea57e57 100644
--- a/server/index.js
+++ b/server/index.js
@@ -1,3 +1,4 @@
+import { deadlineInfo } from '../src/services/tenderCalendar.js';
import { exchangeRates } from './exchange-rates.mjs';
import { uniqueTenders, announcementFields } from './tender-quality.mjs';
/**
@@ -152,13 +153,6 @@ const bugunEklenenSayisi = (liste) => {
return (liste || []).filter((t) => t.kaynak === 'ilan.gov.tr' && t.ilanTarihi === bugun).length;
};
-function daysUntil(ddmmyyyy) {
- if (!ddmmyyyy) return null;
- const [d, m, y] = ddmmyyyy.split('.').map(Number);
- if (!d || !m || !y) return null;
- return Math.ceil((new Date(y, m - 1, d) - new Date()) / 86400000);
-}
-
// ilan.gov.tr kaydını ihaleist tender modeline çevir
function mapAd(ad) {
const cityUpper = (ad.addressCityName || '').toLocaleUpperCase('tr-TR');
@@ -171,7 +165,7 @@ function mapAd(ad) {
const usul = getFilter(ad, 'İhale Usulü') || 'Belirtilmemiş';
const ihaleTuru = getFilter(ad, 'İhale Türü') || '';
const searchBase = `${ad.title || ''} ${ad.advertiserName || ''}`; // yalnızca arama için
- const kalanGun = daysUntil(tarih);
+ const kalanGun = deadlineInfo({ tarih }).days;
let aciliyetBadge = null;
if (kalanGun !== null && kalanGun >= 0 && kalanGun <= 3) aciliyetBadge = '⚠️ SON 3 GÜN';
diff --git a/server/user-store.mjs b/server/user-store.mjs
index ed05ace..c61243b 100644
--- a/server/user-store.mjs
+++ b/server/user-store.mjs
@@ -294,6 +294,16 @@ export function getTenderStates(email) {
return withDb(db => Object.fromEntries(db.prepare('SELECT * FROM tender_state WHERE email=?').all(email)
.map(r => [r.tender_key, { data: JSON.parse(r.data), snapshot: JSON.parse(r.snapshot), version: r.version }])));
}
+// Current per-tender state overrides legacy mirrored follow lists, including unfollow.
+export function getTrackedTenderKeys(email, states = getTenderStates(email)) {
+ const legacy = getData(email, 'takip');
+ const keys = new Set((Array.isArray(legacy) ? legacy : []).map(String));
+ for (const [key, record] of Object.entries(states)) {
+ if (record.data.takipte === true) keys.add(key);
+ else if (record.data.takipte === false) keys.delete(key);
+ }
+ return [...keys];
+}
export function saveTenderState(email, key, patch, snapshot, expectedVersion) {
const fields = new Set(['takipte','incelendi','kanbanStatus','atananPersonel','gorev','notes','checklist','bidDraft']);
if (!key || key.length > 100 || !patch || typeof patch !== 'object' || Array.isArray(patch)
diff --git a/server/zeyilname.mjs b/server/zeyilname.mjs
index ff1e8a7..03f95c2 100644
--- a/server/zeyilname.mjs
+++ b/server/zeyilname.mjs
@@ -59,7 +59,7 @@ export function zeyilnameDurumu() { return { calisiyor, sonTur }; }
function takipHaritasi() {
const harita = new Map();
for (const u of store.listUsers()) {
- const liste = store.getData(u.email, 'takip');
+ const liste = store.getTrackedTenderKeys(u.email);
if (!Array.isArray(liste)) continue;
for (const ikn of liste) {
const k = String(ikn);
diff --git a/tests/feed-deadlines.test.mjs b/tests/feed-deadlines.test.mjs
new file mode 100644
index 0000000..df34070
--- /dev/null
+++ b/tests/feed-deadlines.test.mjs
@@ -0,0 +1,65 @@
+import { test, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const dir = mkdtempSync(join(tmpdir(), 'ihaleist-feed-'));
+process.env.IHALEIST_USER_DATA_DIR = dir;
+const store = await import('../server/user-store.mjs');
+const { ihaleFiltrele, yaklasanIhaleler, trTarih } = await import('../server/feeds-mcp.mjs');
+const { briefMetni, briefHtml, briefScheduleKey } = await import('../server/brief.mjs');
+after(() => rmSync(dir, { recursive: true, force: true }));
+const email = 'dates@example.test';
+const rows = [
+ { id:'past', ikn:'2026/1', tarih:'06.09.2026', saat:'00:00' },
+ { id:'future', ikn:'2026/2', tarih:'06.09.2026', saat:'10:00' },
+ { id:'unknown', ikn:'2026/3', tarih:'06.09.2026', saat:'' },
+ { id:'invalid', ikn:'2026/4', tarih:'31.09.2026', saat:'10:00' },
+ { id:'unfollowed', ikn:'2026/5', tarih:'07.09.2026', saat:'10:00' },
+];
+const now = new Date('2026-09-05T21:30:00Z'); // Istanbul Sep 6, 00:30; UTC still Sep 5.
+
+test('RSS and MCP use Istanbul deadlines regardless of server timezone', () => {
+ const previous = process.env.TZ;
+ try {
+ for (const zone of ['UTC', 'America/Los_Angeles', 'Europe/Istanbul']) {
+ process.env.TZ = zone;
+ assert.deepEqual(ihaleFiltrele(rows, {}, now).map(t => t.id), ['future','unknown','invalid','unfollowed']);
+ assert.equal(trTarih('6/9/2026').toISOString(), '2026-09-05T21:00:00.000Z');
+ assert.equal(trTarih('31.09.2026'), null);
+ assert.equal(trTarih('06.09.2026 garbage'), null);
+ }
+ } finally {
+ if (previous === undefined) delete process.env.TZ;
+ else process.env.TZ = previous;
+ }
+});
+
+test('upcoming API reads saved follows, rejects stale unfollows and includes out-of-pool snapshots', () => {
+ store.setData(email, 'takip', ['2026/5']);
+ for (const t of rows) store.saveTenderState(email, t.ikn, { takipte: t.id !== 'unfollowed' }, t, 0);
+ const outside = { id:'outside', ikn:'2026/6', tarih:'07.09.2026', saat:'09:00' };
+ store.saveTenderState(email, outside.ikn, { takipte:true }, outside, 0);
+ const result = yaklasanIhaleler(email, rows, 14, now);
+ assert.deepEqual(result.map(t => t.ikn), ['2026/2','2026/3','2026/6']);
+ assert.equal(result[0].kalan_gun, 0);
+ assert.equal(result[0].son_teklif_zamani, '2026-09-06T07:00:00.000Z');
+ assert.equal(result[1].saat_dogrulanmali, true);
+ assert.equal(result[1].son_teklif_zamani, null);
+ assert.equal(result[2].kalan_gun, 1);
+ assert.deepEqual(yaklasanIhaleler('other@example.test', rows, 14, now), []);
+ assert.ok(!store.getTrackedTenderKeys(email).includes('2026/5'));
+ const brief = { hafta:'Test haftası', yaklasan:result, sonuclar:[] };
+ for (const output of [briefMetni(brief), briefHtml(brief)]) {
+ assert.match(output, /10:00 \(Türkiye saati\)/);
+ assert.match(output, /saat doğrulanmalı/);
+ }
+});
+
+test('weekly notification schedule starts at Monday 08:00 Istanbul', () => {
+ assert.equal(briefScheduleKey(new Date('2026-09-07T04:59:59Z')), null);
+ assert.equal(briefScheduleKey(new Date('2026-09-07T05:00:00Z')), '2026-8-7');
+ assert.equal(briefScheduleKey(new Date('2026-09-07T20:59:59Z')), '2026-8-7');
+ assert.equal(briefScheduleKey(new Date('2026-09-07T21:00:00Z')), null);
+});