Ana içeriğe geç

Akıllı Sınıf Yoklama Sistemi (ESP32 + RFID + Google Sheets)

Bu belge, ESP32 mikrodenetleyicisi, MFRC522 RFID okuyucu, DS1307 RTC saat modülü ve Google E-Tablolar kullanılarak geliştirilen "Akıllı Sınıf Yoklama Sistemi"nin tüm donanım ve yazılım detaylarını içermektedir.

1. Donanım Mimarisi ve Pin Konfigürasyonu

Sistem, veri işleme ve kablosuz iletişim için ESP32 DevKitC V4 tabanlıdır. Fiziksel kablolamada, özellikle ESP32'nin önyükleme (strapping) kısıtlamaları göz önünde bulundurularak güvenli pinler seçilmiştir.

Bileşen ESP32 Pini (GPIO) Bağlantı Türü / Güvenlik Notu
MFRC522 (RFID) - SDA/SS 5 SPI Veri (Güvenli)
MFRC522 (RFID) - SCK 18 SPI Saat (Güvenli)
MFRC522 (RFID) - MOSI 23 SPI Veri Çıkışı (Güvenli)
MFRC522 (RFID) - MISO 19 SPI Veri Girişi (Güvenli)
MFRC522 (RFID) - RST 4 Reset (Güvenli)
MFRC522 (RFID) - Güç 3.3V (3V3) Kritik: 5V bağlanmamalıdır.
DS1307 (RTC) - SDA 26 I2C Veri (Önyükleme pini değil, %100 güvenli)
DS1307 (RTC) - SCL 25 I2C Saat (Önyükleme pini değil, %100 güvenli)
DS1307 (RTC) - Güç 5V (VIN) Modül 5V gereksinimi duyar.
Yeşil LED 13 Başarılı okuma (Output)
Kırmızı LED 27 Hata/Tekrar durumu (Eski Pin 12 önyükleme riski nedeniyle 27'ye taşındı)
Buzzer 14 Sesli uyarı (Output)

[!warning] Güvenlik Uyarıları - Pin 12 Riski: GPIO 12 bir strapping (önyükleme) pini olduğundan, açılışta HIGH konumuna çekilmesi sistemin flash belleğini kilitleyip Brownout veya Flash Read Error vermesine neden olur. - Sadece Giriş Pinleri: 34, 35, 36 ve 39 numaralı pinler yalnızca giriş (Input Only) olarak kullanılabilir.


2. ESP32 Ürün Yazılımı (FreeRTOS Çift Çekirdek & SPIFFS Kalıcı Hafıza)

Sistem; sıfır veri kaybı, anlık kart tepki süresi ve Wi-Fi kopmalarına karşı tam tolerans sağlamak için FreeRTOS Çift Çekirdek (Dual-Core) ve dahili flaş bellek üzerinde SPIFFS Offline Kuyruk Mimarisi ile çalışmaktadır.

Üretim Kodunu İndir (esp32-yoklama-bitti.ino)

⚙️ Mimari Özellikleri

graph TD
    subgraph Core1 ["🚀 Core 1 (Gerçek Zamanlı UI & Donanım)"]
        RFID["📡 MFRC522 Kart Okuma<br>(5sn Spam Filtreli)"]
        RTC["⏰ DS1307 RTC Zaman Damgası"]
        STATE_MELODY["🎵 Non-blocking Durum Makinesi<br>(Buzzer & LED Bildirimi)"]
        SPIFFS_WRITE["💾 SPIFFS Flaş Belleğe Yazma<br>(/offline.txt)"]
        RFID --> RTC --> STATE_MELODY --> SPIFFS_WRITE
    end

    MUTEX(("🔒 spiffsMutex"))
    SPIFFS_WRITE <--> MUTEX

    subgraph Core0 ["🌐 Core 0 (Bağımsız Ağ & Senkronizasyon Görevi)"]
        TASK["googleGonderTask (xTaskCreatePinnedToCore)"]
        RENAME["Dosya Dönüşümü: /offline.txt ➔ /islem.txt"]
        HTTP_POST["🛰️ Google Apps Script HTTPS İsteği<br>(setReuse: false, stop)"]
        HEALTH["🩺 Watchdog & Arka Arkaya 5 Hata Denetimi"]
        TASK --> RENAME --> HTTP_POST --> HEALTH
    end

    MUTEX <--> RENAME
  • Core 1 (Ana Döngü): RFID kart okuma, RTC'den saniyelik zaman alma ve görsel/işitsel geri bildirimleri yönetir. melodiKontrol() durum makinesi sayesinde bloklayıcı delay() kullanılmaz; öğrenci kartını okuttuğu milisaniyede ses çalar ve cihaz anında sıradaki karta hazır hale gelir.
  • Core 0 (Arka Plan Ağ Görevi): xTaskCreatePinnedToCore ile Core 0'a sabitlenen googleGonderTask, ana döngüyü hiçbir şekilde bloke etmez. Okulun Wi-Fi ağı yavaşlasa dahi kart okuma hızında tek bir milisaniye gecikme yaşanmaz.
  • SPIFFS Offline Kayıt Hafızası: İnternet tamamen kesilse bile kayıtlar flash bellekteki /offline.txt dosyasına yazılır. Bağlantı sağlandığında dosya atomik olarak /islem.txt dosyasına dönüştürülür ve sırayla Google E-Tablo'ya aktarılır.
  • Soket Şişmesi ve RAM Sızıntısı Koruması: http.setReuse(false) ve her istek sonrası secureClient.stop() çağrılarak ESP32'nin TLS soket belleği tertemiz tutulur.
  • Çift Watchdog ve Kendi Kendini Onarma: Core 0'da 90 saniyeden uzun kilitlenme veya art arda 5 bağlantı hatası tespit edilirse donanım otomatik ESP.restart() ile soketlerini yeniler. Ayrıca her gece 03:00'te RTC saatiyle günlük taze reboot atılır.

📜 Tam C++ Üretim Kodu (bitti.ino)

#include <SPI.h>
#include <MFRC522.h>
#include <WiFi.h>
#include <WiFiClientSecure.h> 
#include <HTTPClient.h>
#include <ArduinoOTA.h>
#include <Wire.h>         
#include "RTClib.h"       
#include "SPIFFS.h" 

#define RST_PIN         4
#define SS_PIN          5
#define LED_GREEN       13
#define LED_RED         27 
#define BUZZER_PIN      14 

const char* ssid     = "<WIFI_SSID>"; 
const char* password = "<WIFI_SIFRE>";      
String GOOGLE_SCRIPT_URL = "<GOOGLE_SCRIPT_URL>"; 

MFRC522 mfrc522(SS_PIN, RST_PIN);
RTC_DS1307 rtc;           
unsigned long sonUyanmaZamani = 0;
unsigned long sonSistemKontrol = 0;
int sonRebootGunu = -1; 
String sonOkunanKart = "";
unsigned long sonOkumaZamani = 0;

volatile unsigned long sonCekirdek0Nefesi = 0; 
SemaphoreHandle_t spiffsMutex; 

// ==========================================================
// KUSURSUZ MELODİ DURUM MAKİNESİ (STATE MACHINE)
// ==========================================================
enum MelodiDurumu { MELODI_YOK, MELODI_BASARILI, MELODI_HATA };
MelodiDurumu aktifMelodi = MELODI_YOK;
unsigned long melodiZaman = 0;
int melodiAdim = 0;

void tetikleBasariliMelodi() {
  digitalWrite(LED_GREEN, HIGH);
  digitalWrite(LED_RED, LOW);
  tone(BUZZER_PIN, 3000, 100);
  melodiZaman = millis();
  melodiAdim = 1;
  aktifMelodi = MELODI_BASARILI;
}

void tetikleHataMelodi() {
  digitalWrite(LED_RED, HIGH);
  digitalWrite(LED_GREEN, LOW);
  tone(BUZZER_PIN, 1000, 300);
  melodiZaman = millis();
  melodiAdim = 1;
  aktifMelodi = MELODI_HATA;
}

void melodiKontrol() {
  if (aktifMelodi == MELODI_BASARILI) {
     if (melodiAdim == 1 && millis() - melodiZaman > 150) {
        tone(BUZZER_PIN, 4000, 150);
        melodiAdim = 2;
     } else if (melodiAdim == 2 && millis() - melodiZaman > 350) {
        digitalWrite(LED_GREEN, LOW);
        aktifMelodi = MELODI_YOK;
     }
  }
  else if (aktifMelodi == MELODI_HATA) {
     if (melodiAdim == 1 && millis() - melodiZaman > 350) {
        digitalWrite(LED_RED, LOW);
        aktifMelodi = MELODI_YOK;
     }
  }
}

// ==========================================================
// ARKA PLAN GÖREVİ - CORE 0 (Verileri Google'a Gönderir)
// ==========================================================
void googleGonderTask(void *pvParameters) {
  int ardisikHataSayaci = 0;

  while (true) {
    sonCekirdek0Nefesi = millis(); 

    if (WiFi.status() == WL_CONNECTED) {
      bool islemVar = false;

      if (xSemaphoreTake(spiffsMutex, pdMS_TO_TICKS(500)) == pdTRUE) {
         if (SPIFFS.exists("/offline.txt")) {
            File kontrolF = SPIFFS.open("/offline.txt", FILE_READ);
            if (kontrolF && kontrolF.size() > 0) {
               kontrolF.close();
               SPIFFS.rename("/offline.txt", "/islem.txt");
               islemVar = true;
            } else {
               if (kontrolF) kontrolF.close();
            }
         }
         xSemaphoreGive(spiffsMutex);
      }

      if (islemVar) {
        File islemDosyasi = SPIFFS.open("/islem.txt", FILE_READ);
        bool hataOlustu = false;

        while (islemDosyasi.available()) {
          sonCekirdek0Nefesi = millis(); 

          String satir = islemDosyasi.readStringUntil('\n');
          satir.trim();

          if (satir.length() > 10) {
            int virgul1 = satir.indexOf(',');
            int virgul2 = satir.indexOf(',', virgul1 + 1);

            if (virgul1 > 0 && virgul2 > 0) {
               String id = satir.substring(0, virgul1);
               String saat = satir.substring(virgul1 + 1, virgul2);
               String tarih = satir.substring(virgul2 + 1);

               WiFiClientSecure secureClient;
               secureClient.setInsecure(); 
               HTTPClient http;

               http.setReuse(false); // Soket şişmesini önler

               String url = GOOGLE_SCRIPT_URL + "?kart=" + id + "&saat=" + saat + "&tarih=" + tarih; 

               // 1. DENEME
               http.begin(secureClient, url); 
               http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); 
               http.setTimeout(15000); 
               int httpCode = http.GET();

               // 2. DENEME
               if (httpCode <= 0) {
                  http.end();
                  http.begin(secureClient, url); 
                  http.setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS); 
                  http.setTimeout(15000); 
                  httpCode = http.GET();
               }

               bool kayitSilinebilir = false;
               if (httpCode > 0) {
                   String payload = http.getString();
                   payload.trim();
                   if (payload.indexOf("BASARILI") != -1 || payload.indexOf("CIFT_OKUTMA") != -1 || payload.indexOf("YETKISIZ") != -1) {
                       kayitSilinebilir = true;
                       ardisikHataSayaci = 0;
                       Serial.println("[BAŞARILI] Tabloya işlendi. Yanıt: " + payload);
                   } else {
                       Serial.println("[UYARI] Beklenmeyen Google yanıtı: " + payload);
                   }
               }

               http.end();
               secureClient.stop(); // TLS oturumunu temizle

               if (!kayitSilinebilir) {
                  ardisikHataSayaci++;
                  Serial.println("[HATA] Bağlantı koptu. Hata Sayısı: " + String(ardisikHataSayaci));

                  if (xSemaphoreTake(spiffsMutex, portMAX_DELAY) == pdTRUE) {
                      File geriYazF = SPIFFS.open("/offline.txt", FILE_APPEND);
                      if (geriYazF) {
                          geriYazF.println(satir); 
                          while (islemDosyasi.available()) {
                              String kalanSatir = islemDosyasi.readStringUntil('\n');
                              kalanSatir.trim();
                              if (kalanSatir.length() > 10) geriYazF.println(kalanSatir);
                          }
                          geriYazF.close();
                      }
                      xSemaphoreGive(spiffsMutex);
                  }
                  tetikleHataMelodi();
                  hataOlustu = true;

                  if (ardisikHataSayaci >= 5) {
                      Serial.println("[KRİTİK] Üst üste 5 hata! Yeniden başlatılıyor...");
                      delay(1000);
                      ESP.restart();
                  }
                  break; 
               }
            }
          }
        }
        islemDosyasi.close();

        if (xSemaphoreTake(spiffsMutex, portMAX_DELAY) == pdTRUE) {
            SPIFFS.remove("/islem.txt"); 
            xSemaphoreGive(spiffsMutex);
        }

        if (hataOlustu) vTaskDelay(pdMS_TO_TICKS(5000)); 
      }
    }
    vTaskDelay(pdMS_TO_TICKS(1000)); 
  }
}

// ==========================================================
// KURULUM
// ==========================================================
void setup() {
  Serial.begin(115200); delay(500);
  Serial.println("\n--- Sistem Başlatılıyor ---");

  pinMode(LED_GREEN, OUTPUT); pinMode(LED_RED, OUTPUT); pinMode(BUZZER_PIN, OUTPUT);

  spiffsMutex = xSemaphoreCreateMutex(); 

  if (!SPIFFS.begin(true)) {
    Serial.println("[HATA] Kalıcı Hafıza başlatılamadı!");
  } else {
    if (SPIFFS.exists("/islem.txt")) {
        File islemF = SPIFFS.open("/islem.txt", FILE_READ);
        File offlineF = SPIFFS.open("/offline.txt", FILE_APPEND);
        if (islemF && offlineF) {
            while (islemF.available()) { offlineF.print(islemF.readString()); }
            islemF.close(); offlineF.close();
            SPIFFS.remove("/islem.txt");
        }
    }
  }

  Wire.begin(26, 25);
  if (rtc.begin()) {
    if (!rtc.isrunning()) rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
    sonRebootGunu = rtc.now().day(); 
  }

  SPI.begin(); mfrc522.PCD_Init();
  Serial.println("[BİLGİ] RFID Modülü hazır.");

  WiFi.begin(ssid, password);
  Serial.println("[BİLGİ] Wi-Fi arka planda bağlanıyor...");

  ArduinoOTA.setHostname("Sinif_Yoklama_Sistemi");
  ArduinoOTA.setPassword("<OTA_SIFRESI>"); 
  ArduinoOTA.begin();

  xTaskCreatePinnedToCore(googleGonderTask, "GoogleTask", 16384, NULL, 1, NULL, 0);
  sonCekirdek0Nefesi = millis();

  Serial.println("--- SİSTEM KULLANIMA HAZIR! KART OKUTABİLİRSİNİZ ---");
}

// ==========================================================
// ANA DÖNGÜ - CORE 1 (Kart Okuma)
// ==========================================================
void loop() {
  ArduinoOTA.handle(); 
  melodiKontrol(); 

  // Watchdog kontrolü: Core 0 90 saniyedir nefes almıyorsa reset at
  if (millis() - sonCekirdek0Nefesi > 90000) { 
      digitalWrite(LED_RED, HIGH); delay(1000); ESP.restart();
  }

  // 30 saniyede bir Wi-Fi ve Gece 03:00 Reboot Kontrolü
  if (millis() - sonSistemKontrol > 30000) {
    sonSistemKontrol = millis();
    if (WiFi.status() != WL_CONNECTED) WiFi.reconnect(); 

    DateTime suAn = rtc.now();
    if (suAn.hour() == 3 && sonRebootGunu != suAn.day()) {
      delay(2000); ESP.restart(); 
    }
  }

  // 5 saniyede bir RFID modülünü canlı tut
  if (millis() - sonUyanmaZamani > 5000) {
    mfrc522.PCD_Init(); 
    sonUyanmaZamani = millis();
  }

  if (!mfrc522.PICC_IsNewCardPresent() || !mfrc522.PICC_ReadCardSerial()) return;

  String kartID = "";
  for (byte i = 0; i < mfrc522.uid.size; i++) {
    kartID += String(mfrc522.uid.uidByte[i] < 0x10 ? "0" : "");
    kartID += String(mfrc522.uid.uidByte[i], HEX);
  }
  kartID.toUpperCase();

  // 5 saniye içinde aynı kartın tekrarını donanımsal filtrele
  if (kartID == sonOkunanKart && (millis() - sonOkumaZamani) < 5000) {
    tetikleHataMelodi();
    mfrc522.PICC_HaltA();
    mfrc522.PCD_StopCrypto1(); 
    return; 
  }

  sonOkunanKart = kartID;
  sonOkumaZamani = millis();

  DateTime anlikSaat = rtc.now();
  char metin[50];
  sprintf(metin, "%s,%02d:%02d,%02d.%02d.%04d", kartID.c_str(), anlikSaat.hour(), anlikSaat.minute(), anlikSaat.day(), anlikSaat.month(), anlikSaat.year());

  // SPIFFS Kalıcı Hafızaya Yazma
  bool yazmaBasarili = false;
  if (xSemaphoreTake(spiffsMutex, portMAX_DELAY) == pdTRUE) {
      File f = SPIFFS.open("/offline.txt", FILE_APPEND);
      if (f) {
          size_t yazilan = f.println(metin);
          f.close();
          if (yazilan > 0) yazmaBasarili = true;
      }
      xSemaphoreGive(spiffsMutex);
  }

  if (yazmaBasarili) {
      Serial.println("\n[KAYDEDİLDİ] " + kartID);
      tetikleBasariliMelodi(); 
  } else {
      Serial.println("\n[HATA] Hafızaya yazılamadı!");
      tetikleHataMelodi();
  }

  mfrc522.PICC_HaltA();
  mfrc522.PCD_StopCrypto1(); 
}

3. Google Apps Script Entegrasyonu

Projenin Google E-Tablolar tarafında çalışan üç ana bileşeni (kod dosyası) bulunmaktadır.

A. Arka Plan Mantığı (kod.gs)

Gelen kayıtların değerlendirilmesi aşağıdaki zaman sınırlarına göre yapılır: 1. 00:00 - 08:40 Arası: Zamanında Geldi 2. 08:40 - 12:00 Arası: Geç Kaldı 3. 12:00 - 14:45 Arası: Yarım Gün Geldi 4. 14:45 - 17:00 Arası: Etkinliğe Katıldı

Mevcut kod mimarisinde "Tekrar Okutma Engeli" bulunur. Ancak saat 14:45 itibariyle bu engel esnetilir. Bu sayede sabah okutulan bir kart, öğleden sonraki etkinlik saatinde tekrar okutulduğunda Excel'de öğrenci için ikinci bir kayıt satırı oluşturulur ve tahtadaki görünüm sıfırlanarak yalnızca etkinliğe katılan öğrenciler ekranda aktif (parlak) görünür.

function doGet(e) {
  if (e && e.parameter && e.parameter.kart) { return kartiKaydet(e.parameter.kart); }
  return HtmlService.createHtmlOutputFromFile('Index')
      .setTitle('3-C Sınıfı Yoklama Ekranı')
      .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

function kartiKaydet(okunanKart) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var yoklamaSheet = ss.getSheetByName("Yoklama");
  var ogrencilerSheet = ss.getSheetByName("Ogrenciler");
  var ogrencilerData = ogrencilerSheet.getDataRange().getValues();
  var ogrenciIsmi = "", yetkili = false, avatarLink = "";

  for (var i = 0; i < ogrencilerData.length; i++) {
    if (String(ogrencilerData[i][0]).trim().toUpperCase() === String(okunanKart).trim().toUpperCase()) { 
      ogrenciIsmi = ogrencilerData[i][1]; avatarLink = ogrencilerData[i][2] || ""; yetkili = true; break;
    }
  }

  if (!yetkili) {
    var cache = CacheService.getScriptCache();
    cache.put("sonDurum", JSON.stringify({durum: "YETKISIZ", isim: "Tanınmayan Kart", avatar: ""}), 10);
    return ContentService.createTextOutput("YETKISIZ");
  }

  var simdi = new Date();
  var tarihMetni = Utilities.formatDate(simdi, "GMT+3", "dd.MM.yyyy");
  var saatMetni = Utilities.formatDate(simdi, "GMT+3", "HH:mm");

  // Saate Göre Türkçe Durum Tespiti (Excel'e Yazılacak Metin)
  var kayitDurumu = "Zamanında Geldi"; 
  if (saatMetni > "14:45") { kayitDurumu = "Etkinliğe Katıldı"; }
  else if (saatMetni > "12:00") { kayitDurumu = "Yarım Gün Geldi"; }
  else if (saatMetni > "08:40") { kayitDurumu = "Geç Kaldı"; }

  var sonSatir = yoklamaSheet.getLastRow();

  if (sonSatir > 1) {
    var baslangic = Math.max(2, sonSatir - 150);
    var gecmisVeriler = yoklamaSheet.getRange(baslangic, 1, sonSatir - baslangic + 1, 4).getDisplayValues();

    for (var i = gecmisVeriler.length - 1; i >= 0; i--) {
      // Çocuğun bugün okunmuş bir kaydı var mı diye bakıyoruz
      if (gecmisVeriler[i][3] === ogrenciIsmi && gecmisVeriler[i][0] === tarihMetni) { 

         var eskiSaat = gecmisVeriler[i][1]; // Önceki okutma saati

         // 14:45 KİLİDİ: Eğer çocuk sabahtan okuttuysa ve şu an saat 14:45'ten sonraysa ENGELE TAKILMAZ!
         // (Bu sayede öğrencinin Excel'de 14:45 öncesi ve sonrası olmak üzere 2 kaydı olabilir)
         if (eskiSaat <= "14:45" && saatMetni > "14:45") {
            continue; // Döngüyü atla, çift okutma hatası verme, yeni satır aç.
         }

         // Aksi halde (Sabah veya Etkinlik içinde peş peşe 2 kere basarsa) Uyar ve Engelle!
         var cache = CacheService.getScriptCache();
         cache.put("sonDurum", JSON.stringify({durum: "TEKRAR", isim: ogrenciIsmi, avatar: avatarLink}), 10);
         return ContentService.createTextOutput("CIFT_OKUTMA");
      }
    }
  }

  yoklamaSheet.appendRow([tarihMetni, saatMetni, okunanKart, ogrenciIsmi, kayitDurumu]);
  return ContentService.createTextOutput("BASARILI");
}

function getLatestState(lastRowCount) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName("Yoklama");
  var currentRowCount = sheet.getLastRow();
  var sonuclar = [];

  var cache = CacheService.getScriptCache();
  var sonDurum = cache.get("sonDurum");
  if (sonDurum) {
    cache.remove("sonDurum"); 
    var veri = JSON.parse(sonDurum);
    var mesaj = (veri.durum === "TEKRAR") ? "Zaten Okundu!" : "Kayıtlı Değil!";
    var altMesaj = (veri.durum === "TEKRAR") ? "Hadi yerine geç " + veri.isim + " 😅" : "Öğretmenine başvur 🛑";
    sonuclar.push({ durum: veri.durum, mesaj: mesaj, altMesaj: altMesaj, avatar: veri.avatar, safIsim: veri.isim });
  }

  if (currentRowCount > lastRowCount && lastRowCount > 0) {
    var eklenecekAdet = currentRowCount - lastRowCount;
    var yeniVeriler = sheet.getRange(lastRowCount + 1, 1, eklenecekAdet, 4).getDisplayValues();
    var ogrencilerSheet = ss.getSheetByName("Ogrenciler");
    var ogrencilerData = ogrencilerSheet.getDataRange().getValues();

    for (var i = 0; i < yeniVeriler.length; i++) {
      var saatMetni = yeniVeriler[i][1]; 
      var isim = yeniVeriler[i][3]; 
      var avatarLink = ""; 

      for (var k = 0; k < ogrencilerData.length; k++) {
        if (ogrencilerData[k][1] === isim) { avatarLink = ogrencilerData[k][2] || ""; break; }
      }

      var durum = "NORMAL", mesaj = "Hoş Geldin!", altMesaj = isim + " 🎉";
      if (saatMetni > "14:45") { durum = "ETKINLIK"; mesaj = "Etkinliğe Hoş Geldin!"; altMesaj = "İyi eğlenceler " + isim + " 🎨"; } 
      else if (saatMetni > "12:00") { durum = "YARIMGUN"; mesaj = "Hoş Geldin!"; altMesaj = "Öğleden sonran güzel geçsin " + isim + " ☀️"; } 
      else if (saatMetni > "08:40") { durum = "GEC"; mesaj = "Biraz Geciktik!"; altMesaj = "Hadi acele et " + isim + " 🏃"; }

      sonuclar.push({ durum: durum, mesaj: mesaj, altMesaj: altMesaj, avatar: avatarLink, safIsim: isim });
    }
  }
  return { isNew: sonuclar.length > 0, rowCount: currentRowCount, data: sonuclar };
}

function getTumSinifDurumu() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var yoklamaSheet = ss.getSheetByName("Yoklama");
  var ogrencilerSheet = ss.getSheetByName("Ogrenciler");
  var haftalarSheet = ss.getSheetByName("Haftalar");
  var oturmaSheet = ss.getSheetByName("Oturma");

  var simdi = new Date();
  var bugunTarih = Utilities.formatDate(simdi, "GMT+3", "dd.MM.yyyy");
  var suAnkiSaat = Utilities.formatDate(simdi, "GMT+3", "HH:mm");
  var etkinlikBasladiMi = (suAnkiSaat > "14:45"); // Saat 14:45'i geçti mi?

  var sonSatir = yoklamaSheet.getLastRow();
  var gelenlerSet = {}; 

  if(sonSatir >= 2) {
    var veriler = yoklamaSheet.getRange(2, 1, sonSatir - 1, 5).getDisplayValues();
    for (var i = 0; i < veriler.length; i++) {
      if (veriler[i][0] === bugunTarih && veriler[i][4] !== "") {

          var kayitSaati = veriler[i][1];

          // 14:45 EKRAN SIFIRLAMA MANTIĞI:
          if (!etkinlikBasladiMi) {
              // Eğer etkinlik başlamadıysa (14:45 öncesiyse), sadece etkinlik öncesi kayıtları ekranda göster
              if (kayitSaati <= "14:45") {
                  gelenlerSet[veriler[i][3]] = true; 
              }
          } else {
              // Eğer etkinlik başladıysa (14:45 sonrasıysa), tahtayı sıfırla, SADECE etkinliğe kart okutanları göster
              if (kayitSaati > "14:45") {
                  gelenlerSet[veriler[i][3]] = true; 
              }
          }
      }
    }
  }

  var ogrencilerData = ogrencilerSheet.getDataRange().getValues();
  var ogrenciListesi = [];
  var ogrenciHash = {};

  for (var k = 0; k < ogrencilerData.length; k++) {
    var isim = ogrencilerData[k][1];
    if (isim && isim.trim() !== "") {
      var obj = { isim: isim.trim(), avatar: ogrencilerData[k][2] || "", geldiMi: (gelenlerSet[isim] ? true : false) };
      ogrenciListesi.push(obj);
      ogrenciHash[isim.trim()] = obj;
    }
  }
  ogrenciListesi.sort(function(a, b) { return a.isim.localeCompare(b.isim, 'tr'); });

  // --- HAFTA VE OTURMA PLANI OTOMASYONU ---
  var gecerliHafta = 1; 
  var ciftler = [];

  if (haftalarSheet && oturmaSheet) {
    var bugun = new Date();
    var haftalarData = haftalarSheet.getDataRange().getValues();
    var aylar = {"ocak":0, "şubat":1, "mart":2, "nisan":3, "mayıs":4, "haziran":5, "temmuz":6, "ağustos":7, "eylül":8, "ekim":9, "kasım":10, "aralık":11};

    for (var i = 1; i < haftalarData.length; i++) {
      var haftaNo = haftalarData[i][0].toString().replace(/\D/g,'');
      var tarihStr = haftalarData[i][1];
      if (!tarihStr) continue;

      var parts = tarihStr.toString().trim().replace(/\s+/g, ' ').split(" ");
      if (parts.length >= 3) {
        var day = parseInt(parts[0], 10);
        var monthStr = parts[1].toLowerCase();
        var year = parseInt(parts[2], 10);
        var month = aylar[monthStr] !== undefined ? aylar[monthStr] : 0;
        var baslangicTarihi = new Date(year, month, day);

        if (bugun >= baslangicTarihi) gecerliHafta = parseInt(haftaNo, 10);
        else break; 
      }
    }

    var oturmaData = oturmaSheet.getDataRange().getValues();
    var oturmaPlani = [];

    for (var i = 2; i < oturmaData.length; i++) {
      if (oturmaData[i][0] == gecerliHafta) {
        oturmaPlani = oturmaData[i].slice(1, 41); 
        break;
      }
    }
    if(oturmaPlani.length === 0 && oturmaData.length > 2) {
       oturmaPlani = oturmaData[2].slice(1, 41); 
    }

    for (var i = 0; i < 40; i += 2) {
      var solIsim = oturmaPlani[i] ? oturmaPlani[i].toString().trim() : "";
      var sagIsim = oturmaPlani[i+1] ? oturmaPlani[i+1].toString().trim() : "";

      if (solIsim !== "" || sagIsim !== "") {
         ciftler.push({
            sol: { isim: solIsim, avatar: (ogrenciHash[solIsim] ? ogrenciHash[solIsim].avatar : ""), geldiMi: (ogrenciHash[solIsim] ? ogrenciHash[solIsim].geldiMi : false) },
            sag: { isim: sagIsim, avatar: (ogrenciHash[sagIsim] ? ogrenciHash[sagIsim].avatar : ""), geldiMi: (ogrenciHash[sagIsim] ? ogrenciHash[sagIsim].geldiMi : false) }
         });
      }
    }
  }

  return { ogrenciler: ogrenciListesi, yerlesim: ciftler, hafta: gecerliHafta, sonSatir: sonSatir };
}

function mementoVerileriniCek() {
  var mementoTabloID = "1qJlIhCZPjVTrdkk2t1Z5rNUqQA3hDEU9P6wgFSQg4j4"; 
  var kaynakSS = SpreadsheetApp.openById(mementoTabloID);
  var kaynakSheet = kaynakSS.getSheets()[0]; 
  var kaynakVeriler = kaynakSheet.getDataRange().getDisplayValues();

  if (kaynakVeriler.length <= 1) return; 

  var hedefSS = SpreadsheetApp.getActiveSpreadsheet();
  var yoklamaSheet = hedefSS.getSheetByName("Yoklama");

  for (var i = 1; i < kaynakVeriler.length; i++) {
    var gelenListe = kaynakVeriler[i][2]; 
    if (!gelenListe || gelenListe === "") continue;

    var isimDizisi = gelenListe.split(","); 

    for (var j = 0; j < isimDizisi.length; j++) {
      var ogrenciIsmi = isimDizisi[j].trim();
      if (ogrenciIsmi === "") continue; 

      var okunanKart = "MEMENTO_DB"; 
      var simdi = new Date();
      var tarihMetni = Utilities.formatDate(simdi, "GMT+3", "dd.MM.yyyy");
      var saatMetni = Utilities.formatDate(simdi, "GMT+3", "HH:mm");

      yoklamaSheet.appendRow([tarihMetni, saatMetni, okunanKart, ogrenciIsmi, "Zamanında Geldi"]);
    }
  }

  kaynakSheet.getRange(2, 1, kaynakVeriler.length - 1, kaynakSheet.getLastColumn()).clearContent();
}

[!check] Tetikleyiciler (Triggers) Notu Memento Database uygulamasından gelen verilerin otomatik çekilmesi için, kod.gs içindeki mementoVerileriniCek fonksiyonu Zaman Tabanlı (Time-driven) bir tetikleyici ile (örn: dakikada bir veya saatte bir çalışacak şekilde) Apps Script Tetikleyiciler menüsünden ayarlanmalıdır.

B. Toplu Manuel Giriş (toplugiris.gs)

Öğrencilerin hızlı giriş ekranından toplu olarak işaretlenip kaydedilmesini sağlayan kod bloğu:

function topluGirisKaydet() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var girisSayfasi = ss.getSheetByName("Hizli_Giris");
  var yoklamaSayfasi = ss.getSheetByName("Yoklama");

  var tarih = girisSayfasi.getRange("C1").getValue();
  var saat = girisSayfasi.getRange("C2").getValue();

  // 4. satırdan 50. satıra kadar öğrencileri kontrol et
  for (var i = 4; i <= 50; i++) { 
    var secildiMi = girisSayfasi.getRange(i, 2).getValue(); // B sütunu (Onay Kutusu)
    var ogrenciAd = girisSayfasi.getRange(i, 1).getValue(); // A sütunu (İsim)

    if (secildiMi === true && ogrenciAd !== "") {
      // ÇÖZÜM BURADA: Satırın sonuna "Giris_Basarili" eklendi!
      yoklamaSayfasi.appendRow([tarih, saat, "MANUEL_GIRIS", ogrenciAd, "Giris_Basarili"]);
      girisSayfasi.getRange(i, 2).uncheck(); // İşlem bitince tiki kaldır
    }
  }
  SpreadsheetApp.getUi().alert("Seçilen öğrencilerin girişleri başarıyla eklendi!");
}

C. Akıllı Tahta Arayüzü (Index.html)

Animasyonlar, kayan vitrin ve oturma planını içeren ön yüz dosyası:

<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></script>

  <style>
    body { 
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 
      background-color: #f8f9fa; text-align: center; margin: 0; padding: 0; 
      height: 100vh; display: flex; flex-direction: column; overflow: hidden;
    }

    /* ====================================================
       1. KATMAN: ÜST EKRAN
       ==================================================== */
    #donguAlani { 
      flex: 0.22; 
      display: flex; flex-direction: row; 
      background: linear-gradient(135deg, #74ebd5 0%, #9face6 100%);
      box-shadow: 0 4px 15px rgba(0,0,0,0.15);
      position: relative;
      z-index: 10;
    }

    .dongu-sol {
      flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: flex-start;
      border-right: 2px dashed rgba(255,255,255,0.5); padding: 0 20px; overflow: hidden; position: relative;
    }

    .dongu-baslik { 
      font-size: 1.4vw; font-weight: bold; color: #1e272e; 
      text-shadow: 1px 1px 0px rgba(255,255,255,0.7); 
      margin-bottom: 8px; text-align: left; width: 100%;
    }

    .kayan-ogrenciler-alani {
      width: 100%; overflow: hidden; white-space: nowrap; position: relative; padding: 5px 0;
      mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent); 
      -webkit-mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent);
    }

    .kayan-ogrenciler-seridi {
      display: inline-block;
      padding-left: 100%; 
      animation: seritKayma 15s linear infinite;
    }

    .kayan-ogrenci-kutu {
      display: inline-flex; flex-direction: row; align-items: center; justify-content: flex-start;
      background: rgba(255,255,255,0.95); border-radius: 5vw; padding: 0.3vw 1.5vw 0.3vw 0.3vw;
      box-shadow: 0 4px 12px rgba(0,0,0,0.2); margin-right: 2vw; border: 2px solid #fff;
    }

    .kayan-ogrenci-avatar {
      width: 5.5vw; height: 5.5vw; border-radius: 50%; object-fit: cover; border: 3px solid #2ecc71; flex-shrink: 0;
    }

    .kayan-ogrenci-isim {
      font-size: 1.4vw; font-weight: 900; color: #2c3e50; margin-left: 0.8vw; margin-right: 0.5vw;
    }

    .lokomotif-kutu {
      background: linear-gradient(90deg, #fff3e0, #ffffff) !important;
      border: 3px solid #f39c12 !important;
    }
    .lokomotif-avatar {
      border-color: #f39c12 !important;
    }
    .lokomotif-isim {
      color: #d35400 !important;
    }

    @keyframes seritKayma {
      0% { transform: translateX(0); }
      100% { transform: translateX(-100%); }
    }

    /* --- SAĞ TARAF --- */
    .dongu-sag {
      flex: 1.2; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 0 20px;
    }

    .sinif-yazi-zarif {
      font-size: 4.8vw; font-weight: 900; color: #2c3e50; letter-spacing: 6px;
      margin-bottom: 10px;
      text-shadow: 3px 3px 0px #ffffff, 6px 6px 0px rgba(0,0,0,0.1);
      animation: baslikNefes 2s ease-in-out infinite alternate;
    }

    @keyframes baslikNefes {
      0% { transform: scale(1); text-shadow: 3px 3px 0px #ffffff, 6px 6px 0px rgba(0,0,0,0.1); }
      100% { transform: scale(1.03); text-shadow: 3px 3px 0px #ffffff, 8px 8px 15px rgba(0,0,0,0.25); }
    }

    .hosgeldin-kutu {
      background: rgba(255, 255, 255, 0.65); 
      border-left: 8px solid #ff9f43; 
      padding: 12px 25px;
      border-radius: 12px;
      box-shadow: 0 4px 15px rgba(0,0,0,0.1);
      animation: süzülme 4s ease-in-out infinite;
      max-width: 90%;
    }

    .hosgeldin-metin {
      font-size: 1.3vw; font-weight: 800; margin: 0; line-height: 1.5; 
      color: #1e272e; 
    }

    @keyframes süzülme {
      0%, 100% { transform: translateY(0); }
      50% { transform: translateY(-6px); }
    }

    /* ====================================================
       ALT KISIM: SINIF KROKİSİ
       ==================================================== */
    #panoKapsayici { 
      flex: 0.78; background: #ffffff; padding: 5px 10px 10px 10px; overflow: hidden; 
      display: flex; flex-direction: column;
    }
    .pano-baslik { 
      font-size: 1.2vw; color: #7f8c8d; font-weight: bold; 
      border-bottom: 2px dashed #ecf0f1; padding-bottom: 3px; margin-bottom: 5px;
    }

    .kroki-grid { 
      display: grid; grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(5, 1fr); 
      gap: 0.5vw 0.8vw; width: 100%; flex: 1; box-sizing: border-box;
    }

    .sira-cifti {
      display: flex; flex-direction: row; justify-content: space-between; align-items: center;
      background: #eef2f5; border: 1.5px solid #dcdde1; border-radius: 12px; padding: 0.3vw; 
      box-shadow: inset 0 2px 5px rgba(0,0,0,0.03);
    }

    .bos-alan { background: transparent; border: none; box-shadow: none; }

    .kucuk-kart { 
      display: flex; flex-direction: row; align-items: center; justify-content: flex-start;
      width: 49%; background: #fff; border-radius: 4vw; padding: 0.2vw; box-sizing: border-box;
      transition: all 0.4s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
    }

    .kucuk-avatar { 
      width: 4.2vw; height: 4.2vw; border-radius: 50%; object-fit: cover; flex-shrink: 0; transition: all 0.5s ease; 
    }

    .kucuk-isim { font-size: 0.8vw; font-weight: 800; margin-left: 0.4vw; text-align: left; line-height: 1.1; color: #34495e; }
    .bos-ogrenci { visibility: hidden; } 

    .gelmedi { opacity: 0.6; background: transparent; box-shadow: none; }
    .gelmedi .kucuk-avatar { filter: grayscale(100%); border: 2px dashed #bdc3c7; }

    .geldi { background: #ffffff; border: 1.5px solid #2ecc71; box-shadow: 0 3px 8px rgba(46, 204, 113, 0.25); animation: popIn 0.5s ease-out; }
    .geldi .kucuk-avatar { border: 2.5px solid #2ecc71; }
    .geldi .kucuk-isim { color: #27ae60; }

    /* ====================================================
       2. KATMAN: KUTLAMA ŞÖLENİ
       ==================================================== */
    #kutlamaEkrani {
      position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%);
      z-index: 1000; display: none; flex-direction: column; justify-content: center; align-items: center; transform: translateY(-100%);
    }
    .kutlama-avatar { width: 22vw; height: 22vw; border-radius: 50%; object-fit: cover; border: 8px solid #fff; box-shadow: 0 10px 30px rgba(0,0,0,0.3); margin-bottom: 20px; animation: popAndPulse 2s infinite; }
    .kutlama-baslik { font-size: 5vw; font-weight: 900; color: #fff; text-shadow: 3px 3px 6px rgba(0,0,0,0.4); margin: 0; }
    .kutlama-alt { font-size: 3.5vw; font-weight: bold; color: #ffeb3b; text-shadow: 2px 2px 4px rgba(0,0,0,0.3); }

    @keyframes popIn { 0% { transform: scale(0.9); } 100% { transform: scale(1); } }
    @keyframes popAndPulse { 0% { transform: scale(0.85); } 50% { transform: scale(1.05); } 100% { transform: scale(0.85); } }
    @keyframes slideDown { 0% { transform: translateY(-100%); } 100% { transform: translateY(0); } }
    @keyframes slideUp { 0% { transform: translateY(0); } 100% { transform: translateY(-100%); } }
  </style>
</head>
<body>

  <div id="kutlamaEkrani">
    <img id="kResim" class="kutlama-avatar" src="">
    <div id="kBaslik" class="kutlama-baslik">Hoş Geldin!</div>
    <div id="kAlt" class="kutlama-alt">Öğrenci Adı</div>
  </div>

  <div id="donguAlani">
    <div class="dongu-sol">
      <div class="dongu-baslik">Bugün Bizimle Olanlar</div>

      <!-- KAYAN VİTRİN -->
      <div class="kayan-ogrenciler-alani">
         <div class="kayan-ogrenciler-seridi" id="dSerit">
            <!-- JavaScript burayı dolduracak -->
         </div>
      </div>
    </div>

    <div class="dongu-sag">
      <div class="sinif-yazi-zarif">3-C SINIFI</div>

      <div class="hosgeldin-kutu">
        <!-- Kullanıcının düzenlediği yeni sade metin -->
        <p class="hosgeldin-metin">Hoş geldiniz çocuklar! 
          Kitaplarımızı hazırlayalım,<br>harika bir eğitim gününe başlayalım...</p>
      </div>
    </div>
  </div>

  <div id="panoKapsayici">
    <div class="pano-baslik" id="dinamikBaslik">Sınıf Oturma Planı Yükleniyor...</div>
    <div id="sinifKrokisi"></div>
  </div>

  <script>
    var varsayilanResim = "https://<HA_SUNUCU_ADRESI>/local/c_resim/ogrenci_varsayilan.png"; 
    var lokomotifResim = "https://<HA_SUNUCU_ADRESI>/local/c_resim/ogretmen.png"; 

    var sonSatirSayisi = 0;
    var gelenlerListesi = []; 

    var animasyonKuyrugu = [];
    var animasyonOynuyor = false;
    var kontrolEdiliyor = false; 

    window.onload = function() {
      google.script.run.withSuccessHandler(baslangicYukle).getTumSinifDurumu();
      setInterval(durumKontrolEt, 2000); 
      setInterval(kuyruguIslet, 500); 
    };

    function kisaIsimYap(tamIsim) {
      if(tamIsim === "Tanınmayan Kart") return tamIsim;
      var kelimeler = tamIsim.split(" ");
      return (kelimeler.length > 2) ? kelimeler[0] + " " + kelimeler[1] : kelimeler[0];
    }

    function divIdOlustur(isim) { return "ogr_" + isim.replace(/\s/g, ""); }

    function seridiGuncelle() {
      var serit = document.getElementById("dSerit");

      // Kullanıcının düzenlediği yeni Lokomotif
      var html = `
        <div class="kayan-ogrenci-kutu lokomotif-kutu">
          <img class="kayan-ogrenci-avatar lokomotif-avatar" src="${lokomotifResim}">
          <div class="kayan-ogrenci-isim lokomotif-isim">ÖĞRETMEN</div>
        </div>
      `;

      if (gelenlerListesi.length === 0) {
          html += `<div style="font-size: 1.2vw; color: #2c3e50; font-weight: bold; background: rgba(255,255,255,0.8); padding: 5px 20px; border-radius: 20px; display:inline-block; margin-bottom: 10px;">Öğrenciler Bekleniyor...</div>`;
      } else {
          for(var i = 0; i < gelenlerListesi.length; i++) {
            html += `<div class="kayan-ogrenci-kutu"><img class="kayan-ogrenci-avatar" src="${gelenlerListesi[i].avatar}"><div class="kayan-ogrenci-isim">${gelenlerListesi[i].isim}</div></div>`;
          }
      }

      serit.innerHTML = html;
      var sure = ((gelenlerListesi.length + 1) * 3.5) + 6; 

      serit.style.animation = 'none';
      serit.offsetHeight; 
      serit.style.animation = `seritKayma ${sure}s linear infinite`;
    }

    function baslangicYukle(cevap) {
      sonSatirSayisi = cevap.sonSatir; 
      gelenlerListesi = []; 

      document.getElementById("dinamikBaslik").innerText = cevap.hafta + ". Hafta Sınıf Oturma Planı ve Yoklama Panosu";

      var grid = document.getElementById("sinifKrokisi");
      grid.className = "kroki-grid";
      var htmlIcerik = "";

      htmlIcerik += `<div class="sira-cifti bos-alan"></div>`;
      htmlIcerik += `<div class="sira-cifti bos-alan"></div>`;

      var yerlesimIndex = 0;
      for (var i = 2; i < 20; i++) {
        if (yerlesimIndex < cevap.yerlesim.length) {
            var cift = cevap.yerlesim[yerlesimIndex++];
            htmlIcerik += `<div class="sira-cifti">`;

            if (cift.sol.isim !== "") {
                var kisaIsim = kisaIsimYap(cift.sol.isim);
                var resim = (cift.sol.avatar !== "") ? cift.sol.avatar : varsayilanResim;
                var durumClass = cift.sol.geldiMi ? "geldi" : "gelmedi";
                var htmlId = divIdOlustur(cift.sol.isim);
                htmlIcerik += `<div class="kucuk-kart ${durumClass}" id="${htmlId}"><img class="kucuk-avatar" src="${resim}"><div class="kucuk-isim">${kisaIsim}</div></div>`;
                if (cift.sol.geldiMi) gelenlerListesi.push({ isim: kisaIsim, avatar: resim });
            } else {
                htmlIcerik += `<div class="kucuk-kart bos-ogrenci"></div>`;
            }

            if (cift.sag.isim !== "") {
                var kisaIsim = kisaIsimYap(cift.sag.isim);
                var resim = (cift.sag.avatar !== "") ? cift.sag.avatar : varsayilanResim;
                var durumClass = cift.sag.geldiMi ? "geldi" : "gelmedi";
                var htmlId = divIdOlustur(cift.sag.isim);
                htmlIcerik += `<div class="kucuk-kart ${durumClass}" id="${htmlId}"><img class="kucuk-avatar" src="${resim}"><div class="kucuk-isim">${kisaIsim}</div></div>`;
                if (cift.sag.geldiMi) gelenlerListesi.push({ isim: kisaIsim, avatar: resim });
            } else {
                htmlIcerik += `<div class="kucuk-kart bos-ogrenci"></div>`;
            }

            htmlIcerik += `</div>`;
        } else {
            htmlIcerik += `<div class="sira-cifti bos-alan"></div>`;
        }
      }

      grid.innerHTML = htmlIcerik;
      seridiGuncelle();
    }

    function durumKontrolEt() { 
      if (kontrolEdiliyor) return; 
      kontrolEdiliyor = true; 
      google.script.run.withSuccessHandler(guncelle).getLatestState(sonSatirSayisi); 
    }

    function guncelle(sonuc) {
      sonSatirSayisi = sonuc.rowCount;
      kontrolEdiliyor = false; 
      if (sonuc.isNew && sonuc.data.length > 0) {
        for(var i=0; i<sonuc.data.length; i++) {
          animasyonKuyrugu.push(sonuc.data[i]);
        }
      }
    }

    function kuyruguIslet() {
      if (animasyonOynuyor || animasyonKuyrugu.length === 0) return; 

      animasyonOynuyor = true;
      var veri = animasyonKuyrugu.shift(); 
      var kutlamaEkran = document.getElementById("kutlamaEkrani");

      // =========================================================
      // YENİ: Geçersiz (YETKİSİZ) kart okutulduğunda Öğretmen Resmi Çıkar
      // =========================================================
      if (veri.durum === "YETKISIZ") {
          document.getElementById("kResim").src = lokomotifResim; 
      } else {
          document.getElementById("kResim").src = (veri.avatar && veri.avatar.trim() !== "") ? veri.avatar : varsayilanResim;
      }

      document.getElementById("kBaslik").innerText = veri.mesaj;
      document.getElementById("kAlt").innerText = veri.altMesaj;

      if(veri.durum === "YETKISIZ" || veri.durum === "TEKRAR") {
         kutlamaEkran.style.background = "linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%)";
      } else {
         kutlamaEkran.style.background = "linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)";
      }

      kutlamaEkran.style.display = "flex";
      kutlamaEkran.style.animation = "slideDown 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards";

      if (veri.durum === "NORMAL" || veri.durum === "GEC" || veri.durum === "ETKINLIK") patlatKonfeti();

      setTimeout(function() {
        kutlamaEkran.style.animation = "slideUp 0.6s ease-in forwards";
        setTimeout(function() { 
            kutlamaEkran.style.display = "none"; 
            animasyonOynuyor = false; 
        }, 600); 

        if (veri.durum === "NORMAL" || veri.durum === "GEC" || veri.durum === "ETKINLIK") {
           var htmlId = divIdOlustur(veri.safIsim);
           var kartElementi = document.getElementById(htmlId);
           if(kartElementi) kartElementi.className = "kucuk-kart geldi";

           var listedeVarMi = gelenlerListesi.some(function(ogr) { return ogr.isim === kisaIsimYap(veri.safIsim); });
           if (!listedeVarMi) {
             gelenlerListesi.push({ isim: kisaIsimYap(veri.safIsim), avatar: (veri.avatar || varsayilanResim) });
             seridiGuncelle();
           }
        }
      }, 4000); 
    }

    function patlatKonfeti() {
      var duration = 3000; var animationEnd = Date.now() + duration; var defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 10000 };
      function randomInRange(min, max) { return Math.random() * (max - min) + min; }
      var interval = setInterval(function() {
        var timeLeft = animationEnd - Date.now();
        if (timeLeft <= 0) return clearInterval(interval);
        var particleCount = 50 * (timeLeft / duration);
        confetti(Object.assign({}, defaults, { particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } }));
        confetti(Object.assign({}, defaults, { particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } }));
      }, 250);
    }
  </script>
</body>
</html>

4. Dinamik Yoklama Listesi (Yok_Lis İngilizce Formülü)

Toplanan tüm veriler, "Yok_Lis" sekmesindeki tek bir MAKEARRAY ve LAMBDA tabanlı İngilizce dizi formülü ile görsel bir puantaj tablosuna dönüştürülür. Belirtilen aya ve yıla göre 31 günlük tablo oluşturur; ay 30 veya 28 çekiyorsa fazla sütunları temizler. "Etkinlik Yoklaması" veya "Günlük Yoklama" seçimine göre farklı verileri analiz eder.

Durum Sembolleri: * + (Zamanında veya Etkinliğe Katıldı) * G (Geç Kaldı) * Y (Yarım Gün Geldi) * - (O gün için hiçbir kayıt yok - Devamsız)

=MAKEARRAY(COUNTA($C$3:$C$60); 31; LAMBDA(satir; sutun;
  LET(
    ogrenci; INDEX($C$3:$C$60; satir);
    hedefTarih; DATE($AE$1; $AA$1; sutun);
    tarihMetni; TEXT(hedefTarih; "dd.MM.yyyy");
    ayGecerli; MONTH(hedefTarih) = $AA$1;
    tur; $E$1;

    IF(NOT(ayGecerli); "";
      IF(tur="Etkinlik Yoklaması";
        IF(COUNTIFS(Yoklama!$D:$D; ogrenci; Yoklama!$A:$A; tarihMetni; Yoklama!$E:$E; "Etkinliğe Katıldı")>0; "+"; "");

        LET(
          zamaninda; COUNTIFS(Yoklama!$D:$D; ogrenci; Yoklama!$A:$A; tarihMetni; Yoklama!$E:$E; "Zamanında Geldi") + COUNTIFS(Yoklama!$D:$D; ogrenci; Yoklama!$A:$A; tarihMetni; Yoklama!$E:$E; "Giris_Basarili");
          gec; COUNTIFS(Yoklama!$D:$D; ogrenci; Yoklama!$A:$A; tarihMetni; Yoklama!$E:$E; "Geç Kaldı");
          yarim; COUNTIFS(Yoklama!$D:$D; ogrenci; Yoklama!$A:$A; tarihMetni; Yoklama!$E:$E; "Yarım Gün Geldi");

          IFS(
            zamaninda>0; "+";
            gec>0; "G";
            yarim>0; "Y";
            TRUE; "-"
          )
        )
      )
    )
  )
))