fix: unify tender countdowns and add monthly calendar navigation
This commit is contained in:
@@ -56,9 +56,16 @@ Tarayıcı kanıtı: [Yenileme sonrası beş kart](evidence/qa-kanban-after-relo
|
|||||||
- Oturum geri yükleme, kayıt/hata, plan, analiz, firma taraması ve bildirim yanıtları oturum değişmişse yeni hesaba uygulanmaz. Başka sekmedeki oturum değişikliği dinlenir. Ziyaret ve belge uyarısı anahtarları hesaba ayrılır.
|
- Oturum geri yükleme, kayıt/hata, plan, analiz, firma taraması ve bildirim yanıtları oturum değişmişse yeni hesaba uygulanmaz. Başka sekmedeki oturum değişikliği dinlenir. Ziyaret ve belge uyarısı anahtarları hesaba ayrılır.
|
||||||
- İki regresyon testi eski oturum yanıtının yeni hesabı silememesini/değiştirememesini ve ortak ihale önbelleğinin özel hazırlık durumunu taşımamasını doğrular. Tarayıcıda iki gerçek test hesabı ve offline senaryosu ayrıca kabul edilmeli. Bu değişiklik, özel profil verisinin offline erişimini sunucu senkronu tamamlanana kadar sınırlar.
|
- İki regresyon testi eski oturum yanıtının yeni hesabı silememesini/değiştirememesini ve ortak ihale önbelleğinin özel hazırlık durumunu taşımamasını doğrular. Tarayıcıda iki gerçek test hesabı ve offline senaryosu ayrıca kabul edilmeli. Bu değişiklik, özel profil verisinin offline erişimini sunucu senkronu tamamlanana kadar sınırlar.
|
||||||
|
|
||||||
|
## Ortak tarih ve aylık takvim
|
||||||
|
|
||||||
|
- Kanban ve yaklaşan tarihler aynı Türkiye saati/gün hesabını kullanır. Aynı günün geçmiş saatleri artık yaklaşan listesinde açık gibi görünmez; saati bilinmeyen kayıtta belirsizlik açıkça belirtilir.
|
||||||
|
- Takvim ay seçimine açıldı; 22 Eylül gibi ilk yedi gün dışındaki adaylara erişilir. Ay uzunluğu/artık gün ve boş gün durumu ele alındı. Takvim kartı klavyeyle açılır.
|
||||||
|
- Açık kalan Kanban, sağ panel ve takvim tarih etiketleri 30 saniyede bir güncellenir. Ürün aramasındaki geçmiş tarih filtresi aynı kaynak saatini kullanır; filtre etiketi bilinmeyen saati başvuruya açık garantisi gibi sunmaz.
|
||||||
|
- Üç tarih testi gün farkını, Türkiye'de yıl/gece geçişini, tam kapanış anını, eksik saat ve artık günleri doğrular. Google/Outlook gerçek içe aktarımı, zeyilname güncellemesi ve bütün sunucu bildirim zamanlayıcıları henüz bu kapsamda doğrulanmadı.
|
||||||
|
|
||||||
## Doğrulama
|
## Doğrulama
|
||||||
|
|
||||||
`npm test`: 27/27 geçti (son yeniden çalıştırma: 6 Eylül 2026).
|
`npm test`: 30/30 geçti (son yeniden çalıştırma: 6 Eylül 2026).
|
||||||
`npm run build`: geçti; mevcut büyük paket/dinamik import uyarıları sürüyor.
|
`npm run build`: geçti; mevcut büyük paket/dinamik import uyarıları sürüyor.
|
||||||
`node --check server/index.js`, `node --check server/user-store.mjs`, `git diff --check`: geçti.
|
`node --check server/index.js`, `node --check server/user-store.mjs`, `git diff --check`: geçti.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { useDeadlineClock } from '../hooks/useDeadlineClock';
|
||||||
|
import { deadlineInfo } from '../services/tenderCalendar';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { Kanban, User, Calendar, MapPin, Star, Plus, ArrowRight, Eye } from 'lucide-react';
|
import { Kanban, User, Calendar, MapPin, Star, Plus, ArrowRight, Eye } from 'lucide-react';
|
||||||
|
|
||||||
export const KanbanBoard = () => {
|
export const KanbanBoard = () => {
|
||||||
|
const now = useDeadlineClock();
|
||||||
const { tenders, updateKanbanStatus, setActiveTenderDetail, teamMembers } = useApp();
|
const { tenders, updateKanbanStatus, setActiveTenderDetail, teamMembers } = useApp();
|
||||||
const [selectedPersonel, setSelectedPersonel] = useState('');
|
const [selectedPersonel, setSelectedPersonel] = useState('');
|
||||||
const [draggedTenderId, setDraggedTenderId] = useState(null);
|
const [draggedTenderId, setDraggedTenderId] = useState(null);
|
||||||
@@ -128,13 +131,11 @@ export const KanbanBoard = () => {
|
|||||||
#{t.ikn}
|
#{t.ikn}
|
||||||
</span>
|
</span>
|
||||||
{t.gorev && (() => {
|
{t.gorev && (() => {
|
||||||
// Teslim = ihale saati; kalan gün geri sayım (gecikme eskalasyonu kendiliğinden)
|
const info = deadlineInfo(t, now);
|
||||||
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/.exec(t.gorev.teslim || '');
|
const kalan = info.days;
|
||||||
if (!m) return null;
|
const renk = info.expired || kalan === null ? 'bg-slate-200 text-slate-600' : kalan <= 2 ? 'bg-red-100 text-red-800' : kalan <= 7 ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800';
|
||||||
const kalan = Math.ceil((new Date(+m[3], +m[2] - 1, +m[1], +(m[4] || 23), +(m[5] || 59)) - Date.now()) / 86400000);
|
return <span className={`text-[10px] font-extrabold px-2 py-0.5 rounded ${renk}`} title={`İhale: ${t.tarih} ${t.saat || 'saat bilinmiyor'} (Türkiye saati)`}>
|
||||||
const renk = kalan < 0 ? 'bg-slate-200 text-slate-600' : kalan <= 2 ? 'bg-red-100 text-red-800' : kalan <= 7 ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800';
|
{info.label}
|
||||||
return <span className={`text-[10px] font-extrabold px-2 py-0.5 rounded ${renk}`} title={`Teslim: ${t.gorev.teslim}`}>
|
|
||||||
{kalan < 0 ? 'süresi geçti' : kalan === 0 ? 'BUGÜN' : `${kalan} gün`}
|
|
||||||
</span>;
|
</span>;
|
||||||
})()}
|
})()}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { deadlineInfo } from '../services/tenderCalendar';
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import {
|
import {
|
||||||
@@ -16,11 +17,9 @@ import { okasAra } from '../services/kalem';
|
|||||||
|
|
||||||
const SAYFA = 20;
|
const SAYFA = 20;
|
||||||
|
|
||||||
const gecmisMi = (tarihSaat) => {
|
const gecmisMi = tarihSaat => {
|
||||||
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/.exec(String(tarihSaat || ''));
|
const [tarih, saat] = String(tarihSaat || '').trim().split(/\s+/);
|
||||||
if (!m) return false;
|
return deadlineInfo({tarih,saat}).expired === true;
|
||||||
const [, g, a, y, ss = '23', dd = '59'] = m;
|
|
||||||
return new Date(+y, +a - 1, +g, +ss, +dd) < new Date();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UrunAramaModal = () => {
|
export const UrunAramaModal = () => {
|
||||||
@@ -217,7 +216,7 @@ export const UrunAramaModal = () => {
|
|||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-1.5 font-semibold text-slate-700 cursor-pointer">
|
<label className="flex items-center gap-1.5 font-semibold text-slate-700 cursor-pointer">
|
||||||
<input type="checkbox" checked={sadeceAcik} onChange={(e) => setSadeceAcik(e.target.checked)} className="accent-indigo-600" />
|
<input type="checkbox" checked={sadeceAcik} onChange={(e) => setSadeceAcik(e.target.checked)} className="accent-indigo-600" />
|
||||||
Sadece teklif verilebilir ihaleler
|
Süresi geçtiği bilinenleri gizle
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -293,7 +292,7 @@ export const UrunAramaModal = () => {
|
|||||||
{liste.length === 0 && (
|
{liste.length === 0 && (
|
||||||
<p className="text-xs text-slate-500 text-center py-6">
|
<p className="text-xs text-slate-500 text-center py-6">
|
||||||
{sonuc.totalCount > 0
|
{sonuc.totalCount > 0
|
||||||
? 'Bu sayfadaki sonuçların tarihi geçmiş. "Sadece teklif verilebilir" kutusunu kapatarak görebilirsiniz.'
|
? 'Bu sayfadaki sonuçların tarihi geçmiş. "Süresi geçtiği bilinenleri gizle" kutusunu kapatarak görebilirsiniz.'
|
||||||
: 'Bu ürün adı hiçbir teklif cetvelinde bulunamadı. Daha kısa/genel bir terim deneyin.'}
|
: 'Bu ürün adı hiçbir teklif cetvelinde bulunamadı. Daha kısa/genel bir terim deneyin.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
|
import { useDeadlineClock } from '../hooks/useDeadlineClock';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { CalendarClock } from 'lucide-react';
|
import { CalendarClock } from 'lucide-react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { parseDateTR } from '../services/analytics';
|
import { deadlineInfo } from '../services/tenderCalendar';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sağ panel: takipteki / panodaki ihalelerden 14 gün içinde yapılacaklar.
|
* Sağ panel: takipteki / panodaki ihalelerden 14 gün içinde yapılacaklar.
|
||||||
* Sunucuya gitmez — liste zaten bellekte. ≤3 gün kırmızı, ≤7 turuncu.
|
* Sunucuya gitmez — liste zaten bellekte. ≤3 gün kırmızı, ≤7 turuncu.
|
||||||
*/
|
*/
|
||||||
export const YaklasanSonTarihler = ({ gun = 14 }) => {
|
export const YaklasanSonTarihler = ({ gun = 14 }) => {
|
||||||
|
const now = useDeadlineClock();
|
||||||
const { tenders, setActiveTenderDetail } = useApp();
|
const { tenders, setActiveTenderDetail } = useApp();
|
||||||
const liste = useMemo(() => {
|
const liste = useMemo(() => {
|
||||||
const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
|
|
||||||
const sinir = new Date(bugun.getTime() + gun * 86400000);
|
|
||||||
return tenders
|
return tenders
|
||||||
.filter((t) => t.takipte || t.kanbanStatus || t.gorev)
|
.filter(t => t.takipte || t.gorev)
|
||||||
.map((t) => ({ t, d: parseDateTR(t.tarih) }))
|
.map(t => ({t,info:deadlineInfo(t,now)}))
|
||||||
.filter(({ d }) => d && d >= bugun && d <= sinir)
|
.filter(({info}) => info.days !== null && info.days >= 0 && info.days <= gun && info.expired !== true)
|
||||||
.sort((a, b) => a.d - b.d)
|
.sort((a,b) => a.info.days - b.info.days || (a.info.deadline?.getTime() || Infinity) - (b.info.deadline?.getTime() || Infinity))
|
||||||
.map(({ t, d }) => ({ t, kalan: Math.round((d - bugun) / 86400000) }))
|
.map(({t,info}) => ({t,kalan:info.days,label:info.label}))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}, [tenders, gun]);
|
}, [tenders, gun, now]);
|
||||||
|
|
||||||
const renk = (g) => (g <= 3 ? 'bg-red-600 text-white' : g <= 7 ? 'bg-amber-500 text-white' : 'bg-slate-200 text-slate-800');
|
const renk = (g) => (g <= 3 ? 'bg-red-600 text-white' : g <= 7 ? 'bg-amber-500 text-white' : 'bg-slate-200 text-slate-800');
|
||||||
|
|
||||||
@@ -33,11 +33,11 @@ export const YaklasanSonTarihler = ({ gun = 14 }) => {
|
|||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
{liste.length === 0 ? (
|
{liste.length === 0 ? (
|
||||||
<p className="px-2 py-3 text-xs text-slate-500">{gun} gün içinde yapılacak takipli ihale yok. Yıldızla takibe alın; burada geri sayım başlar.</p>
|
<p className="px-2 py-3 text-xs text-slate-500">{gun} gün içinde yapılacak takipli ihale yok. Yıldızla takibe alın; burada geri sayım başlar.</p>
|
||||||
) : liste.map(({ t, kalan }) => (
|
) : liste.map(({ t, kalan, label }) => (
|
||||||
<button key={t.id} type="button" onClick={() => setActiveTenderDetail?.(t)}
|
<button key={t.id} type="button" onClick={() => setActiveTenderDetail?.(t)}
|
||||||
className="w-full flex items-center justify-between gap-2 rounded-lg p-2 text-xs hover:bg-slate-50 text-left">
|
className="w-full flex items-center justify-between gap-2 rounded-lg p-2 text-xs hover:bg-slate-50 text-left">
|
||||||
<span className="min-w-0 truncate font-medium text-slate-800" title={t.baslik}>{t.baslik}</span>
|
<span className="min-w-0 truncate font-medium text-slate-800" title={t.baslik}>{t.baslik}</span>
|
||||||
<span className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-extrabold ${renk(kalan)}`}>{kalan === 0 ? 'BUGÜN' : `${kalan} gün`}</span>
|
<span className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-extrabold ${renk(kalan)}`}>{label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
|
import { useDeadlineClock } from '../../hooks/useDeadlineClock';
|
||||||
|
import { calendarMonthDays, istanbulDateKey, tenderDateKey, deadlineInfo } from '../../services/tenderCalendar';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
import { CalendarDays, Clock, MapPin, AlertCircle } from 'lucide-react';
|
import { CalendarDays, Clock, MapPin, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
export const TenderCalendar = () => {
|
export const TenderCalendar = () => {
|
||||||
|
const now = useDeadlineClock();
|
||||||
const { tenders, setActiveTenderDetail } = useApp();
|
const { tenders, setActiveTenderDetail } = useApp();
|
||||||
const GUNLER = ['Pazar', 'Pazartesi', 'Salı', 'Çarşamba', 'Perşembe', 'Cuma', 'Cumartesi'];
|
const today = istanbulDateKey(now);
|
||||||
const ggaayyyy = (d) => `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
|
const [month,setMonth] = useState(today.slice(0,7));
|
||||||
// Bugünden başlayan 7 gün; sayılar havuzdaki gerçek ihale tarihlerinden.
|
const [selectedDay, setSelectedDay] = useState(today);
|
||||||
const daysList = Array.from({ length: 7 }, (_, i) => {
|
const daysList = calendarMonthDays(month).map(day => ({...day,count:tenders.filter(t=>tenderDateKey(t)===day.key).length}));
|
||||||
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + i);
|
const dayTenders = tenders.filter(t => tenderDateKey(t) === selectedDay);
|
||||||
const date = ggaayyyy(d);
|
|
||||||
return { date, label: i === 0 ? `Bugün (${GUNLER[d.getDay()]})` : i === 1 ? `Yarın (${GUNLER[d.getDay()]})` : GUNLER[d.getDay()], count: tenders.filter(t => t.tarih === date).length };
|
|
||||||
});
|
|
||||||
const [selectedDay, setSelectedDay] = useState(daysList[0].date);
|
|
||||||
|
|
||||||
const dayTenders = tenders.filter(t => t.tarih === selectedDay);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 my-4 animate-fade-in">
|
<div className="space-y-4 my-4 animate-fade-in">
|
||||||
@@ -26,30 +23,34 @@ export const TenderCalendar = () => {
|
|||||||
<CalendarDays className="w-6 h-6" />
|
<CalendarDays className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-black text-slate-900">7 Günlük İhale Takvimi</h1>
|
<h1 className="text-xl font-black text-slate-900">İhale Takvimi</h1>
|
||||||
<p className="text-xs text-slate-500">Önümüzdeki 7 gün içerisinde kapanacak ihaleleri günü gününe takip edin.</p>
|
<p className="text-xs text-slate-500">Ay ve gün seçerek ihale tarihlerini görüntüleyin. Saatler Türkiye saatidir.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-sm font-semibold">Takvim ayı
|
||||||
|
<input type="month" value={month} onChange={e => {if(calendarMonthDays(e.target.value).length){setMonth(e.target.value);setSelectedDay(e.target.value+'-01');}}}
|
||||||
|
className="ml-3 border border-slate-300 rounded-lg p-2"/>
|
||||||
|
</label>
|
||||||
{/* Day Selector Buttons */}
|
{/* Day Selector Buttons */}
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2">
|
||||||
{daysList.map(d => (
|
{daysList.map(d => (
|
||||||
<button
|
<button
|
||||||
key={d.date}
|
key={d.key}
|
||||||
onClick={() => setSelectedDay(d.date)}
|
onClick={() => setSelectedDay(d.key)}
|
||||||
className={`flex-1 min-w-[130px] p-3 rounded-xl border text-center transition-all cursor-pointer ${
|
className={`min-w-0 p-3 rounded-xl border text-center transition-all cursor-pointer ${
|
||||||
selectedDay === d.date
|
selectedDay === d.key
|
||||||
? 'bg-purple-600 text-white border-purple-700 shadow-sm font-bold'
|
? 'bg-purple-600 text-white border-purple-700 shadow-sm font-bold'
|
||||||
: 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50 font-semibold'
|
: 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50 font-semibold'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[11px] block text-purple-200 opacity-90">{d.label}</span>
|
<span className="text-[11px] block opacity-90">{d.label}</span>
|
||||||
<span className="text-sm font-black block mt-0.5">{d.date}</span>
|
<span className="text-sm font-black block mt-0.5">{d.date}</span>
|
||||||
<span className={`text-[10px] mt-1 inline-block px-2 py-0.5 rounded-full ${
|
<span className={`text-[10px] mt-1 inline-block px-2 py-0.5 rounded-full ${
|
||||||
selectedDay === d.date ? 'bg-white/20 text-white font-extrabold' : 'bg-purple-100 text-purple-800 font-bold'
|
selectedDay === d.key ? 'bg-white/20 text-white font-extrabold' : 'bg-purple-100 text-purple-800 font-bold'
|
||||||
}`}>
|
}`}>
|
||||||
{d.count} İhale Kapanıyor
|
{d.count} İhale
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -57,16 +58,19 @@ export const TenderCalendar = () => {
|
|||||||
|
|
||||||
{/* Tenders List for Selected Day */}
|
{/* Tenders List for Selected Day */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-bold">{selectedDay.split('-').reverse().join('.')} tarihli ihaleler</h2>
|
||||||
|
{!dayTenders.length && <p className="text-sm text-slate-500">Bu gün için yüklenen havuzda ihale yok.</p>}
|
||||||
{dayTenders.map(t => (
|
{dayTenders.map(t => (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
role="button" tabIndex={0} onKeyDown={e=>{if(e.key==='Enter' || e.key===' '){e.preventDefault();setActiveTenderDetail(t);}}}
|
||||||
onClick={() => setActiveTenderDetail(t)}
|
onClick={() => setActiveTenderDetail(t)}
|
||||||
className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md cursor-pointer transition-all flex items-center justify-between gap-4"
|
className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md cursor-pointer transition-all flex items-center justify-between gap-4"
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="bg-purple-100 text-purple-900 text-[10px] font-extrabold px-2 py-0.5 rounded">
|
<span className="bg-purple-100 text-purple-900 text-[10px] font-extrabold px-2 py-0.5 rounded">
|
||||||
⏰ {t.saat}
|
⏰ {deadlineInfo(t, now).deadline?.toLocaleTimeString('tr-TR',{hour:'2-digit',minute:'2-digit',timeZone:'Europe/Istanbul'}) || 'Saat doğrulanmalı'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-400 font-mono">#{t.ikn}</span>
|
<span className="text-xs text-slate-400 font-mono">#{t.ikn}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
// Keep expiry labels current even when the user leaves a screen open.
|
||||||
|
export function useDeadlineClock() {
|
||||||
|
const [now,setNow] = useState(() => new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(new Date()),30000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
},[]);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
@@ -36,3 +36,32 @@ export function createTenderICS(t, now = new Date()) {
|
|||||||
`DESCRIPTION:${escapeICS('İKN: '+t.ikn+' — '+t.kurum)}`,
|
`DESCRIPTION:${escapeICS('İKN: '+t.ikn+' — '+t.kurum)}`,
|
||||||
'END:VEVENT','END:VCALENDAR',''].map(fold).join('\r\n');
|
'END:VEVENT','END:VCALENDAR',''].map(fold).join('\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const istanbulDateKey = (now = new Date()) => new Date(now.getTime() + 3 * 3600000).toISOString().slice(0,10);
|
||||||
|
export function tenderDateKey(t) {
|
||||||
|
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:$|\s)/.exec(String(t?.tarih || ''));
|
||||||
|
if (!m) return null;
|
||||||
|
const key = `${m[3]}-${m[2]}-${m[1]}`;
|
||||||
|
const date = new Date(key + 'T00:00:00Z');
|
||||||
|
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0,10) === key ? key : null;
|
||||||
|
}
|
||||||
|
export function deadlineInfo(t, now = new Date()) {
|
||||||
|
const dateKey = tenderDateKey(t);
|
||||||
|
if (!dateKey) return {dateKey:null,days:null,expired:null,label:'Tarih doğrulanmalı',deadline:null};
|
||||||
|
const days = Math.round((Date.parse(dateKey+'T00:00:00Z') - Date.parse(istanbulDateKey(now)+'T00:00:00Z')) / 86400000);
|
||||||
|
let deadline = null;
|
||||||
|
try { deadline = tenderDeadline(t); } catch { /* an unknown time is not midnight */ }
|
||||||
|
const expired = deadline ? deadline.getTime() <= now.getTime() : days < 0 ? true : null;
|
||||||
|
const label = expired ? 'Süresi geçti' : `${days === 0 ? 'BUGÜN' : days + ' gün'}${deadline ? '' : ' · saat doğrulanmalı'}`;
|
||||||
|
return {dateKey,days,expired,label,deadline};
|
||||||
|
}
|
||||||
|
export function calendarMonthDays(month) {
|
||||||
|
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) return [];
|
||||||
|
const [year,m] = month.split('-').map(Number);
|
||||||
|
const count = new Date(Date.UTC(year,m,0)).getUTCDate();
|
||||||
|
return Array.from({length:count},(_,i)=>{
|
||||||
|
const day=String(i+1).padStart(2,'0');
|
||||||
|
return {key:`${month}-${day}`,date:`${day}.${String(m).padStart(2,'0')}.${year}`,
|
||||||
|
label:new Date(`${month}-${day}T12:00:00Z`).toLocaleDateString('tr-TR',{weekday:'short',timeZone:'Europe/Istanbul'})};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createTenderICS, tenderDeadline } from '../src/services/tenderCalendar.js';
|
import { createTenderICS, tenderDeadline, deadlineInfo, calendarMonthDays, istanbulDateKey } from '../src/services/tenderCalendar.js';
|
||||||
const t = {ikn:'2026/1582991',baslik:'Mikroskop',kurum:'Giresun',tarih:'10.09.2026',saat:'',aciklama:'Tarih ve Saati : 10.09.2026 - 10:00'};
|
const t = {ikn:'2026/1582991',baslik:'Mikroskop',kurum:'Giresun',tarih:'10.09.2026',saat:'',aciklama:'Tarih ve Saati : 10.09.2026 - 10:00'};
|
||||||
test('Giresun date is September 10 at 10:00 Turkey, not hard-coded August', () => {
|
test('Giresun date is September 10 at 10:00 Turkey, not hard-coded August', () => {
|
||||||
assert.equal(tenderDeadline(t).toISOString(),'2026-09-10T07:00:00.000Z');
|
assert.equal(tenderDeadline(t).toISOString(),'2026-09-10T07:00:00.000Z');
|
||||||
@@ -23,3 +23,27 @@ test('ICS escapes text, folds UTF-8 lines, keeps stable UID', () => {
|
|||||||
assert.equal(ics.split('UID:')[1].split('\r\n')[0],'2026%2F1582991@ihaleist.com');
|
assert.equal(ics.split('UID:')[1].split('\r\n')[0],'2026%2F1582991@ihaleist.com');
|
||||||
assert.ok(ics.includes('\\nEND:VEVENT\\;\\,'));
|
assert.ok(ics.includes('\\nEND:VEVENT\\;\\,'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('all countdowns use Istanbul calendar days and exact expiry', () => {
|
||||||
|
const now = new Date('2026-09-06T08:00:00Z');
|
||||||
|
assert.equal(deadlineInfo(t,now).days,4);
|
||||||
|
assert.equal(deadlineInfo(t,now).label,'4 gün');
|
||||||
|
assert.equal(deadlineInfo(t,new Date('2026-09-10T06:59:00Z')).expired,false);
|
||||||
|
assert.equal(deadlineInfo(t,new Date('2026-09-10T07:00:00Z')).expired,true);
|
||||||
|
assert.equal(istanbulDateKey(new Date('2026-12-31T22:00:00Z')),'2027-01-01');
|
||||||
|
});
|
||||||
|
test('missing time stays unknown today, impossible date is not counted', () => {
|
||||||
|
const now=new Date('2026-09-10T10:00:00Z');
|
||||||
|
const info=deadlineInfo({tarih:'10.09.2026'},now);
|
||||||
|
assert.equal(info.expired,null);assert.equal(info.deadline,null);
|
||||||
|
assert.match(info.label,/saat doğrulanmalı/);
|
||||||
|
assert.equal(deadlineInfo({tarih:'31.02.2026'},now).days,null);
|
||||||
|
assert.equal(deadlineInfo({tarih:'09.09.2026'},now).expired,true);
|
||||||
|
});
|
||||||
|
test('month view includes late-month tenders and leap days', () => {
|
||||||
|
assert.equal(calendarMonthDays('2026-09').length,30);
|
||||||
|
assert.ok(calendarMonthDays('2026-09').some(d=>d.date==='22.09.2026'));
|
||||||
|
assert.equal(calendarMonthDays('2028-02').length,29);
|
||||||
|
assert.equal(calendarMonthDays('2027-02').length,28);
|
||||||
|
assert.deepEqual(calendarMonthDays('2026-13'),[]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user