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):
xTaskCreatePinnedToCoreile Core 0'a sabitlenengoogleGonderTask, 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.txtdosyasına yazılır. Bağlantı sağlandığında dosya atomik olarak/islem.txtdosyası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.gsiçindekimementoVerileriniCekfonksiyonu 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; "-"
)
)
)
)
)
))