diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index b5dea57..8a1ce84 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -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. - İ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 -`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. `node --check server/index.js`, `node --check server/user-store.mjs`, `git diff --check`: geçti. diff --git a/src/components/KanbanBoard.jsx b/src/components/KanbanBoard.jsx index c3b7c95..2adf4ab 100644 --- a/src/components/KanbanBoard.jsx +++ b/src/components/KanbanBoard.jsx @@ -1,8 +1,11 @@ +import { useDeadlineClock } from '../hooks/useDeadlineClock'; +import { deadlineInfo } from '../services/tenderCalendar'; import React, { useState } from 'react'; import { useApp } from '../context/AppContext'; import { Kanban, User, Calendar, MapPin, Star, Plus, ArrowRight, Eye } from 'lucide-react'; export const KanbanBoard = () => { + const now = useDeadlineClock(); const { tenders, updateKanbanStatus, setActiveTenderDetail, teamMembers } = useApp(); const [selectedPersonel, setSelectedPersonel] = useState(''); const [draggedTenderId, setDraggedTenderId] = useState(null); @@ -128,13 +131,11 @@ export const KanbanBoard = () => { #{t.ikn} {t.gorev && (() => { - // Teslim = ihale saati; kalan gün geri sayım (gecikme eskalasyonu kendiliğinden) - const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/.exec(t.gorev.teslim || ''); - if (!m) return null; - const kalan = Math.ceil((new Date(+m[3], +m[2] - 1, +m[1], +(m[4] || 23), +(m[5] || 59)) - Date.now()) / 86400000); - 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'; - return - {kalan < 0 ? 'süresi geçti' : kalan === 0 ? 'BUGÜN' : `${kalan} gün`} + const info = deadlineInfo(t, now); + const kalan = info.days; + 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'; + return + {info.label} ; })()} ))} diff --git a/src/components/tools/TenderCalendar.jsx b/src/components/tools/TenderCalendar.jsx index 681c209..81d851e 100644 --- a/src/components/tools/TenderCalendar.jsx +++ b/src/components/tools/TenderCalendar.jsx @@ -1,20 +1,17 @@ +import { useDeadlineClock } from '../../hooks/useDeadlineClock'; +import { calendarMonthDays, istanbulDateKey, tenderDateKey, deadlineInfo } from '../../services/tenderCalendar'; import React, { useState } from 'react'; import { useApp } from '../../context/AppContext'; import { CalendarDays, Clock, MapPin, AlertCircle } from 'lucide-react'; export const TenderCalendar = () => { + const now = useDeadlineClock(); const { tenders, setActiveTenderDetail } = useApp(); - const GUNLER = ['Pazar', 'Pazartesi', 'Salı', 'Çarşamba', 'Perşembe', 'Cuma', 'Cumartesi']; - const ggaayyyy = (d) => `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; - // Bugünden başlayan 7 gün; sayılar havuzdaki gerçek ihale tarihlerinden. - const daysList = Array.from({ length: 7 }, (_, i) => { - const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + i); - 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); + const today = istanbulDateKey(now); + const [month,setMonth] = useState(today.slice(0,7)); + const [selectedDay, setSelectedDay] = useState(today); + const daysList = calendarMonthDays(month).map(day => ({...day,count:tenders.filter(t=>tenderDateKey(t)===day.key).length})); + const dayTenders = tenders.filter(t => tenderDateKey(t) === selectedDay); return (
@@ -26,30 +23,34 @@ export const TenderCalendar = () => {
-

7 Günlük İhale Takvimi

-

Önümüzdeki 7 gün içerisinde kapanacak ihaleleri günü gününe takip edin.

+

İhale Takvimi

+

Ay ve gün seçerek ihale tarihlerini görüntüleyin. Saatler Türkiye saatidir.

+ {/* Day Selector Buttons */} -
+
{daysList.map(d => ( ))} @@ -57,16 +58,19 @@ export const TenderCalendar = () => { {/* Tenders List for Selected Day */}
+

{selectedDay.split('-').reverse().join('.')} tarihli ihaleler

+ {!dayTenders.length &&

Bu gün için yüklenen havuzda ihale yok.

} {dayTenders.map(t => (
{if(e.key==='Enter' || e.key===' '){e.preventDefault();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" >
- ⏰ {t.saat} + ⏰ {deadlineInfo(t, now).deadline?.toLocaleTimeString('tr-TR',{hour:'2-digit',minute:'2-digit',timeZone:'Europe/Istanbul'}) || 'Saat doğrulanmalı'} #{t.ikn}
diff --git a/src/hooks/useDeadlineClock.js b/src/hooks/useDeadlineClock.js new file mode 100644 index 0000000..5c92b7b --- /dev/null +++ b/src/hooks/useDeadlineClock.js @@ -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; +} diff --git a/src/services/tenderCalendar.js b/src/services/tenderCalendar.js index b2e3bc2..a8848f4 100644 --- a/src/services/tenderCalendar.js +++ b/src/services/tenderCalendar.js @@ -36,3 +36,32 @@ export function createTenderICS(t, now = new Date()) { `DESCRIPTION:${escapeICS('İKN: '+t.ikn+' — '+t.kurum)}`, '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'})}; + }); +} diff --git a/tests/calendar.test.mjs b/tests/calendar.test.mjs index ce18368..554fa34 100644 --- a/tests/calendar.test.mjs +++ b/tests/calendar.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; 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'}; 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'); @@ -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.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'),[]); +});