Files
Faecher-Countdown/countdown.html
2026-04-08 11:16:44 +02:00

733 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Countdown - Französisch</title>
<!-- GSAP for Animations -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
/* =========================================
DESIGN SYSTEM & CSS RESET
Strict Monochrome, Clean Layout
========================================= */
:root {
--bg-color: #ffffff;
--text-color: #000000;
--border-color: #e5e5e5;
--subtext-color: #666666;
--font-main: 'Söhne Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
[data-theme="dark"] {
--bg-color: #000000;
--text-color: #ffffff;
--border-color: #222222;
--subtext-color: #aaaaaa;
}
/* Controls (Theme & Fullscreen) */
.controls {
position: absolute;
top: 1.5rem;
right: 1.5rem;
display: flex;
gap: 1.5rem;
z-index: 100;
}
.icon-btn {
background: none;
border: none;
color: var(--text-color);
cursor: pointer;
opacity: 0.4;
transition: opacity 0.2s ease, color 0.5s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
}
.icon-btn:hover {
opacity: 1;
}
.icon-btn svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Progress Bars */
.progress-bar {
position: fixed;
left: 0;
height: 2px;
background-color: var(--text-color);
z-index: 50;
transition: width 1s linear, background-color 0.5s ease;
width: 0%;
}
.class-progress {
top: 0;
}
.year-progress {
bottom: 0;
opacity: 0.2;
}
/* Lessons Badge */
.lessons-badge {
font-size: 0.875rem;
font-weight: 500;
color: var(--subtext-color);
margin-top: -1.5rem;
margin-bottom: 2.5rem;
letter-spacing: 0.02em;
}
/* Dynamic Monochrome / Urgent State */
.countdown-wrapper {
transition: transform 0.3s ease;
}
.countdown-wrapper.urgent .digit-box {
animation: urgent-pulse 1s infinite alternate;
}
@keyframes urgent-pulse {
0% {
opacity: 0.7;
font-weight: 800;
transform: scale(1);
text-shadow: 0 0 0px transparent;
}
100% {
opacity: 1;
font-weight: 900;
transform: scale(1.05);
text-shadow: 0 0 15px var(--text-color);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-main);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.5s ease, color 0.5s ease;
}
/* Container & Layout */
.app-container {
width: 100%;
max-width: 800px;
padding: 2rem;
text-align: center;
/* Hidden initially for GSAP entry animation */
opacity: 0;
transform: translateY(20px);
}
/* Typography */
h1.subject-title {
font-size: clamp(3rem, 8vw, 5rem);
font-weight: 800;
letter-spacing: -0.04em;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.status-badge {
display: inline-block;
padding: 0.5rem 1rem;
font-size: 1rem;
font-weight: 600;
border: 1px solid var(--text-color);
border-radius: 999px;
margin-bottom: 3rem;
letter-spacing: 0.05em;
text-transform: uppercase;
transition: background-color 0.5s ease, color 0.5s ease, border-color 0.5s ease;
}
/* Main Countdown Blocks */
.countdown-wrapper {
display: flex;
justify-content: center;
align-items: center;
gap: clamp(1rem, 4vw, 3rem);
margin-bottom: 4rem;
}
.time-block {
display: flex;
flex-direction: column;
align-items: center;
}
.digit-box {
position: relative;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
height: 1.1em;
/* Tight bounding box for sliding effect */
font-size: clamp(4rem, 10vw, 7rem);
font-weight: 800;
font-variant-numeric: tabular-nums;
/* Prevents jumping widths */
letter-spacing: -0.02em;
line-height: 1;
}
.digit {
position: relative;
width: 100%;
}
.digit-clone {
position: absolute;
top: 100%;
left: 0;
width: 100%;
text-align: center;
}
.time-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--subtext-color);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-top: 0.5rem;
}
/* Separator */
.separator {
width: 100%;
height: 1px;
background-color: var(--border-color);
margin: 3rem 0;
transition: background-color 0.5s ease;
}
/* Secondary Countdown (Total Time) */
.total-time-container {
display: flex;
flex-direction: column;
align-items: center;
opacity: 0.8;
}
.total-time-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--subtext-color);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.total-countdown-wrapper {
display: flex;
gap: 1.5rem;
}
.total-time-block {
display: flex;
flex-direction: column;
align-items: center;
}
.total-digit-box {
font-size: 2rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
position: relative;
overflow: hidden;
height: 1.1em;
line-height: 1;
}
.total-time-label {
font-size: 0.7rem;
color: var(--subtext-color);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
/* Finished State */
.finished-message {
display: none;
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.02em;
margin-top: 2rem;
}
/* Utilities */
.hidden {
display: none !important;
}
</style>
</head>
<body>
<!-- Micro-Progress Bars -->
<div id="class-progress" class="progress-bar class-progress hidden"></div>
<div id="year-progress" class="progress-bar year-progress"></div>
<div class="controls">
<button class="icon-btn" id="fullscreen-toggle" aria-label="Toggle Fullscreen">
<svg id="icon-expand" viewBox="0 0 24 24">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3">
</path>
</svg>
<svg id="icon-compress" class="hidden" viewBox="0 0 24 24">
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3">
</path>
</svg>
</button>
<button class="icon-btn" id="theme-toggle" aria-label="Toggle Theme">
<svg id="icon-moon" viewBox="0 0 24 24">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>
<svg id="icon-sun" class="hidden" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>
</button>
</div>
<div class="app-container" id="app">
<!-- Header -->
<h1 class="subject-title">Französisch</h1>
<div class="status-badge" id="status-badge">Lade...</div>
<div class="lessons-badge" id="lessons-badge">Berechne verbleibende Stunden...</div>
<!-- Main Dynamic Countdown (State A & B) -->
<div class="countdown-wrapper" id="main-countdown">
<div class="time-block" id="block-main-days">
<div class="digit-box">
<div class="digit" id="val-main-days">00</div>
</div>
<div class="time-label">Tage</div>
</div>
<div class="time-block" id="block-main-hours">
<div class="digit-box">
<div class="digit" id="val-main-hours">00</div>
</div>
<div class="time-label">Stunden</div>
</div>
<div class="time-block" id="block-main-minutes">
<div class="digit-box">
<div class="digit" id="val-main-minutes">00</div>
</div>
<div class="time-label">Minuten</div>
</div>
<div class="time-block" id="block-main-seconds">
<div class="digit-box">
<div class="digit" id="val-main-seconds">00</div>
</div>
<div class="time-label">Sekunden</div>
</div>
</div>
<div class="finished-message" id="finished-msg">Geschafft!</div>
<div class="separator" id="separator"></div>
<!-- Secondary Countdown (State C) -->
<div class="total-time-container" id="total-container">
<div class="total-time-title">Schuljahresende (24. Juli 2026)</div>
<div class="total-countdown-wrapper">
<div class="total-time-block">
<div class="total-digit-box">
<div class="digit" id="val-total-days">00</div>
</div>
<div class="total-time-label">Tage</div>
</div>
<div class="total-time-block">
<div class="total-digit-box">
<div class="digit" id="val-total-hours">00</div>
</div>
<div class="total-time-label">Std</div>
</div>
<div class="total-time-block">
<div class="total-digit-box">
<div class="digit" id="val-total-minutes">00</div>
</div>
<div class="total-time-label">Min</div>
</div>
<div class="total-time-block">
<div class="total-digit-box">
<div class="digit" id="val-total-seconds">00</div>
</div>
<div class="total-time-label">Sek</div>
</div>
</div>
</div>
</div>
<script>
/* =========================================
CONFIGURATION & CONSTANTS
========================================= */
// Final school year end date (Europe/Berlin CEST timezone explicitly defined via offset +02:00 for summer time)
const END_DATE = new Date('2026-07-24T23:59:59+02:00');
const START_DATE = new Date('2025-09-09T08:00:00+02:00'); // Start of school year for progress calculations
// Weekly Schedule (0 = Sunday, 1 = Monday, 3 = Wednesday)
const SCHEDULE = [
{ day: 1, startH: 10, startM: 35, endH: 11, endM: 20 },
{ day: 3, startH: 9, startM: 30, endH: 10, endM: 15 },
{ day: 3, startH: 10, startM: 35, endH: 11, endM: 20 }
];
// Bavarian Holidays (Inclusive). Offsets adjusted for CET/CEST to prevent midnight shifts.
const HOLIDAYS = [
{ start: new Date('2026-03-30T00:00:00+02:00'), end: new Date('2026-04-10T23:59:59+02:00') }, // Osterferien
{ start: new Date('2026-05-01T00:00:00+02:00'), end: new Date('2026-05-01T23:59:59+02:00') }, // Tag der Arbeit
{ start: new Date('2026-05-14T00:00:00+02:00'), end: new Date('2026-05-14T23:59:59+02:00') }, // Christi Himmelfahrt
{ start: new Date('2026-05-25T00:00:00+02:00'), end: new Date('2026-06-05T23:59:59+02:00') } // Pfingstferien
];
/* =========================================
TIME & LOGIC ENGINE
========================================= */
// Helper to get exact current time in Europe/Berlin to avoid local user timezone mismatches
function getBerlinTime() {
const str = new Date().toLocaleString('en-US', { timeZone: 'Europe/Berlin' });
return new Date(str);
}
// Check if a specific date falls within defined holiday arrays
function isHoliday(dateObj) {
for (let holiday of HOLIDAYS) {
if (dateObj >= holiday.start && dateObj <= holiday.end) {
return true;
}
}
return false;
}
// Core Algorithm: Find the next valid lesson or determine if currently in class
function getNextLessonStatus(now) {
// Scan up to 365 days into the future
for (let i = 0; i < 365; i++) {
let checkDate = new Date(now.getTime());
checkDate.setDate(checkDate.getDate() + i);
checkDate.setHours(0, 0, 0, 0);
if (isHoliday(checkDate)) continue; // Skip holidays
let dayOfWeek = checkDate.getDay();
let dayLessons = SCHEDULE.filter(s => s.day === dayOfWeek);
for (let lesson of dayLessons) {
let lessonStart = new Date(checkDate.getTime());
lessonStart.setHours(lesson.startH, lesson.startM, 0, 0);
let lessonEnd = new Date(checkDate.getTime());
lessonEnd.setHours(lesson.endH, lesson.endM, 0, 0);
if (i === 0) {
// It's today. Are we currently in this class?
if (now >= lessonStart && now < lessonEnd) {
return { state: 'IN_CLASS', targetDate: lessonEnd, start: lessonStart, end: lessonEnd };
}
// It's today, but the class is in the future
if (now < lessonStart) {
return { state: 'WAITING', targetDate: lessonStart };
}
} else {
// It's a future day. This is the next chronological class.
return { state: 'WAITING', targetDate: lessonStart };
}
}
}
return { state: 'FINISHED', targetDate: null };
}
// Calculate actual remaining lessons, skipping holidays
function getRemainingLessons(now) {
let count = 0;
let checkDate = new Date(now.getTime());
checkDate.setHours(0, 0, 0, 0);
const end = new Date(END_DATE.getTime());
while (checkDate <= end) {
if (!isHoliday(checkDate)) {
let dayOfWeek = checkDate.getDay();
let dayLessons = SCHEDULE.filter(s => s.day === dayOfWeek);
for (let lesson of dayLessons) {
let lessonEnd = new Date(checkDate.getTime());
lessonEnd.setHours(lesson.endH, lesson.endM, 0, 0);
// Only count if the lesson ends in the future
if (lessonEnd > now) {
count++;
}
}
}
checkDate.setDate(checkDate.getDate() + 1);
}
return count;
}
// Calculate time differences
function getTimeDiff(target, now) {
let diff = target - now;
if (diff < 0) diff = 0;
return {
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
minutes: Math.floor((diff / 1000 / 60) % 60),
seconds: Math.floor((diff / 1000) % 60)
};
}
/* =========================================
UI & ANIMATION CONTROLLER
========================================= */
// GSAP Number Flip Animation: Only animates if the number actually changes
function animateDigit(elementId, newValue) {
const el = document.getElementById(elementId);
const strVal = String(newValue).padStart(2, '0');
if (el && el.innerText !== strVal && !el.dataset.isAnimating) {
el.dataset.isAnimating = "true";
const parent = el.parentElement;
// Create clone for smooth sliding effect
const clone = document.createElement('div');
clone.className = 'digit digit-clone';
clone.innerText = strVal;
parent.appendChild(clone);
// Slide old up/fade out, slide new up/fade in
gsap.to(el, { y: '-100%', opacity: 0, duration: 0.4, ease: "power2.inOut" });
gsap.to(clone, {
y: '-100%', opacity: 1, duration: 0.4, ease: "power2.inOut", onComplete: () => {
el.innerText = strVal;
gsap.set(el, { y: '0%', opacity: 1 });
clone.remove();
el.dataset.isAnimating = "";
}
});
} else if (el && el.innerText !== strVal) {
// Fallback if animation flags get stuck
el.innerText = strVal;
}
}
// Master Tick Function
function updateTick() {
const now = getBerlinTime();
// Feature: Year Progress
const yearTotal = END_DATE - START_DATE;
const yearElapsed = now - START_DATE;
const yearPct = Math.min(100, Math.max(0, (yearElapsed / yearTotal) * 100));
document.getElementById('year-progress').style.width = yearPct + '%';
// Feature: Lessons Remaining
const lessonsLeft = getRemainingLessons(now);
document.getElementById('lessons-badge').innerText = `Nur noch ${lessonsLeft} Stunden dieses Jahr.`;
// Check State D: Completely Finished
if (now >= END_DATE) {
document.getElementById('status-badge').innerText = "Schuljahr beendet";
document.getElementById('main-countdown').classList.add('hidden');
document.getElementById('lessons-badge').classList.add('hidden');
document.getElementById('total-container').classList.add('hidden');
document.getElementById('separator').classList.add('hidden');
document.getElementById('finished-msg').style.display = 'block';
return;
}
// Update State C: Total Time Left
const totalDiff = getTimeDiff(END_DATE, now);
animateDigit('val-total-days', totalDiff.days);
animateDigit('val-total-hours', totalDiff.hours);
animateDigit('val-total-minutes', totalDiff.minutes);
animateDigit('val-total-seconds', totalDiff.seconds);
// Calculate current lesson status
const status = getNextLessonStatus(now);
// State A: In Class
if (status.state === 'IN_CLASS') {
document.getElementById('status-badge').innerText = "Aktuell im Unterricht!";
document.getElementById('status-badge').style.borderColor = "var(--text-color)";
document.getElementById('status-badge').style.backgroundColor = "var(--text-color)";
document.getElementById('status-badge').style.color = "var(--bg-color)";
// Hide days and hours blocks
document.getElementById('block-main-days').classList.add('hidden');
document.getElementById('block-main-hours').classList.add('hidden');
const lessonDiff = getTimeDiff(status.targetDate, now);
animateDigit('val-main-minutes', lessonDiff.minutes);
animateDigit('val-main-seconds', lessonDiff.seconds);
// Feature: Class Progress Bar
const totalDuration = status.end - status.start;
const elapsed = now - status.start;
const classPct = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
document.getElementById('class-progress').style.width = classPct + '%';
document.getElementById('class-progress').classList.remove('hidden');
// Feature: Dynamic Monochrome Opacity (< 60s)
if (lessonDiff.minutes === 0 && lessonDiff.hours === 0) {
document.getElementById('main-countdown').classList.add('urgent');
} else {
document.getElementById('main-countdown').classList.remove('urgent');
}
}
// State B: Waiting
else if (status.state === 'WAITING') {
document.getElementById('status-badge').innerText = "Nächste Stunde in:";
document.getElementById('status-badge').style.borderColor = "var(--text-color)";
document.getElementById('status-badge').style.backgroundColor = "transparent";
document.getElementById('status-badge').style.color = "var(--text-color)";
// Show all blocks
document.getElementById('block-main-days').classList.remove('hidden');
document.getElementById('block-main-hours').classList.remove('hidden');
const waitingDiff = getTimeDiff(status.targetDate, now);
animateDigit('val-main-days', waitingDiff.days);
animateDigit('val-main-hours', waitingDiff.hours);
animateDigit('val-main-minutes', waitingDiff.minutes);
animateDigit('val-main-seconds', waitingDiff.seconds);
// Hide class progress and urgent state when not in class
document.getElementById('class-progress').classList.add('hidden');
document.getElementById('main-countdown').classList.remove('urgent');
}
}
/* =========================================
INITIALIZATION
========================================= */
window.onload = () => {
// Theme setup
const toggleBtn = document.getElementById('theme-toggle');
const iconMoon = document.getElementById('icon-moon');
const iconSun = document.getElementById('icon-sun');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const setThemeIcons = (theme) => {
if (theme === 'dark') {
iconMoon.classList.add('hidden');
iconSun.classList.remove('hidden');
} else {
iconMoon.classList.remove('hidden');
iconSun.classList.add('hidden');
}
};
let currentTheme = 'light';
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
currentTheme = 'dark';
}
setThemeIcons(currentTheme);
toggleBtn.addEventListener('click', () => {
currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', currentTheme);
localStorage.setItem('theme', currentTheme);
setThemeIcons(currentTheme);
});
// Feature: Fullscreen Toggle
const fsBtn = document.getElementById('fullscreen-toggle');
const iconExpand = document.getElementById('icon-expand');
const iconCompress = document.getElementById('icon-compress');
fsBtn.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => console.log(err));
} else {
document.exitFullscreen();
}
});
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement) {
iconExpand.classList.add('hidden');
iconCompress.classList.remove('hidden');
} else {
iconExpand.classList.remove('hidden');
iconCompress.classList.add('hidden');
}
});
// Run initial tick to populate data immediately
updateTick();
// Start the interval engine
setInterval(updateTick, 1000);
// Elegant Entry Animation (Claude Style)
gsap.to("#app", {
opacity: 1,
y: 0,
duration: 0.8,
ease: "power3.out"
});
gsap.from(".time-block, .total-time-block", {
y: 20,
opacity: 0,
duration: 0.6,
stagger: 0.1,
ease: "power2.out",
delay: 0.2
});
};
</script>
</body>
</html>