initial commit
All checks were successful
Build and Push Image / build-and-push (push) Successful in 1m26s

This commit is contained in:
Michael Trip 2026-01-09 21:58:53 +01:00
parent 3bba1f6db6
commit b56e866071
36 changed files with 4160 additions and 0 deletions

27
app/__init__.py Normal file
View file

@ -0,0 +1,27 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from config import Config
db = SQLAlchemy()
login_manager = LoginManager()
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'main.login'
login_manager.login_message = 'Inloggen vereist om deze pagina te bekijken.'
from app.models import User
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
from app.routes import bp as main_bp
app.register_blueprint(main_bp)
return app

51
app/models.py Normal file
View file

@ -0,0 +1,51 @@
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
class User(UserMixin, db.Model):
"""Gebruiker model voor geregistreerde gebruikers"""
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relatie met incidents
incidents = db.relationship('Incident', backref='user', lazy='dynamic', cascade='all, delete-orphan')
def set_password(self, password):
"""Hash het wachtwoord"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""Controleer het wachtwoord"""
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.email}>'
class Incident(db.Model):
"""Incident model voor het bijhouden van snauwgedrag"""
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) # Null voor anonieme gebruikers
timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
severity = db.Column(db.Integer, nullable=False) # 1-5 Snauw-index schaal
notes = db.Column(db.Text, nullable=True) # Optionele opmerkingen
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Voor anonieme gebruikers
session_id = db.Column(db.String(255), nullable=True, index=True) # Voor anonieme tracking
def __repr__(self):
return f'<Incident {self.id}: severity={self.severity} at {self.timestamp}>'
def to_dict(self):
"""Converteer naar dictionary voor JSON responses"""
return {
'id': self.id,
'timestamp': self.timestamp.isoformat(),
'severity': self.severity,
'notes': self.notes or '',
'created_at': self.created_at.isoformat()
}

447
app/routes.py Normal file
View file

@ -0,0 +1,447 @@
from datetime import datetime, timedelta
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash
from sqlalchemy import func, desc
import uuid
import json
from app import db
from app.models import User, Incident
bp = Blueprint('main', __name__)
@bp.route('/health')
def health_check():
"""Health check endpoint voor Kubernetes"""
try:
# Check database connectivity
db.session.execute('SELECT 1')
return jsonify({
'status': 'healthy',
'timestamp': datetime.utcnow().isoformat(),
'database': 'connected'
}), 200
except Exception as e:
return jsonify({
'status': 'unhealthy',
'timestamp': datetime.utcnow().isoformat(),
'database': 'disconnected',
'error': str(e)
}), 503
@bp.route('/')
def index():
"""Homepage met incident invoer"""
return render_template('index.html')
@bp.route('/add-incident', methods=['GET', 'POST'])
def add_incident():
"""Incident toevoegen (voor geregistreerde gebruikers)"""
if request.method == 'POST':
try:
severity_str = request.form.get('severity')
notes = request.form.get('notes', '').strip()
incident_time = request.form.get('incident_time')
print(f"DEBUG add_incident - Severity: {severity_str}, Time: {incident_time}")
# Validatie
if not severity_str:
flash('Selecteer een Snauw-index', 'error')
return redirect(url_for('main.add_incident'))
severity = int(severity_str)
if not (1 <= severity <= 5):
flash('Snauw-index moet tussen 1 en 5 zijn', 'error')
return redirect(url_for('main.add_incident'))
# Parse timestamp
if incident_time:
try:
# Handle different datetime formats
if 'T' in incident_time:
timestamp = datetime.fromisoformat(incident_time.replace('Z', '+00:00'))
else:
timestamp = datetime.strptime(incident_time, '%Y-%m-%d %H:%M:%S')
except ValueError as dt_error:
print(f"DEBUG DateTime parse error: {dt_error}")
timestamp = datetime.utcnow()
else:
timestamp = datetime.utcnow()
# Maak incident
incident = Incident(
user_id=current_user.id if current_user.is_authenticated else None,
timestamp=timestamp,
severity=severity,
notes=notes if notes else None,
session_id=get_or_create_session_id() if not current_user.is_authenticated else None
)
db.session.add(incident)
db.session.commit()
flash('✅ Incident succesvol opgeslagen!', 'success')
return redirect(url_for('main.statistics'))
except ValueError as e:
print(f"DEBUG ValueError in add_incident: {e}")
flash('Ongeldige invoer. Controleer je gegevens.', 'error')
except Exception as e:
print(f"DEBUG Exception in add_incident: {e}")
flash('Er is een fout opgetreden bij het opslaan.', 'error')
db.session.rollback()
return render_template('add_incident.html')
@bp.route('/api/incidents', methods=['POST'])
def api_add_incident():
"""API endpoint voor het toevoegen van incidenten (voor AJAX)"""
try:
data = request.get_json()
severity = int(data.get('severity'))
notes = data.get('notes', '').strip()
timestamp_str = data.get('timestamp')
if not (1 <= severity <= 5):
return jsonify({'error': 'Snauw-index moet tussen 1 en 5 zijn'}), 400
# Parse timestamp
if timestamp_str:
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
else:
timestamp = datetime.utcnow()
# Maak incident
incident = Incident(
user_id=current_user.id if current_user.is_authenticated else None,
timestamp=timestamp,
severity=severity,
notes=notes if notes else None,
session_id=get_or_create_session_id() if not current_user.is_authenticated else None
)
db.session.add(incident)
db.session.commit()
return jsonify({
'success': True,
'message': 'Incident succesvol opgeslagen!',
'incident': incident.to_dict()
})
except ValueError:
return jsonify({'error': 'Ongeldige invoer'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': 'Er is een fout opgetreden'}), 500
@bp.route('/statistics')
def statistics():
"""Statistieken pagina"""
# Voor ingelogde gebruikers
if current_user.is_authenticated:
incidents = Incident.query.filter_by(user_id=current_user.id).order_by(desc(Incident.timestamp)).all()
else:
# Voor anonieme gebruikers - via session_id
session_id = get_or_create_session_id()
incidents = Incident.query.filter_by(session_id=session_id).order_by(desc(Incident.timestamp)).all()
# Bereken statistieken
stats = calculate_statistics(incidents)
return render_template('statistics.html', incidents=incidents, stats=stats)
@bp.route('/api/statistics')
def api_statistics():
"""API endpoint voor statistieken (voor AJAX updates)"""
if current_user.is_authenticated:
incidents = Incident.query.filter_by(user_id=current_user.id).all()
else:
session_id = get_or_create_session_id()
incidents = Incident.query.filter_by(session_id=session_id).all()
stats = calculate_statistics(incidents)
return jsonify({
'stats': stats,
'incidents': [incident.to_dict() for incident in incidents]
})
@bp.route('/register', methods=['GET', 'POST'])
def register():
"""Gebruiker registratie"""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
password = request.form.get('password')
password_confirm = request.form.get('password_confirm')
# Debug informatie
print(f"DEBUG Register - Email: {email}, Password len: {len(password) if password else 0}")
# Validatie
if not email or '@' not in email or '.' not in email:
flash('Voer een geldig e-mailadres in', 'error')
return render_template('register.html')
if not password or len(password) < 6:
flash('Wachtwoord moet minstens 6 karakters lang zijn', 'error')
return render_template('register.html')
if password != password_confirm:
flash('Wachtwoorden komen niet overeen', 'error')
return render_template('register.html')
# Check of gebruiker al bestaat
if User.query.filter_by(email=email).first():
flash('Er bestaat al een account met dit e-mailadres', 'error')
return render_template('register.html')
try:
# Maak nieuwe gebruiker
user = User(email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
# Probeer data migratie van anonieme gebruiker
migrate_anonymous_data(user)
# Log in de gebruiker
login_user(user)
flash('✅ Account succesvol aangemaakt en ingelogd!', 'success')
return redirect(url_for('main.index'))
except Exception as e:
print(f"DEBUG Register error: {e}")
db.session.rollback()
flash('Er is een fout opgetreden bij het maken van je account', 'error')
return render_template('register.html')
return render_template('register.html')
@bp.route('/login', methods=['GET', 'POST'])
def login():
"""Gebruiker login"""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
password = request.form.get('password')
if not email or not password:
flash('Voer e-mail en wachtwoord in', 'error')
return render_template('login.html')
user = User.query.filter_by(email=email).first()
if user and user.check_password(password):
login_user(user)
# Probeer data migratie van anonieme gebruiker
migrate_anonymous_data(user)
flash('✅ Succesvol ingelogd!', 'success')
next_page = request.args.get('next')
return redirect(next_page) if next_page else redirect(url_for('main.index'))
else:
flash('Ongeldig e-mailadres of wachtwoord', 'error')
return render_template('login.html')
@bp.route('/logout')
@login_required
def logout():
"""Gebruiker logout"""
logout_user()
flash('Je bent uitgelogd', 'info')
return redirect(url_for('main.index'))
@bp.route('/migrate-data', methods=['POST'])
def migrate_data():
"""Data migratie van anonieme naar geregistreerde gebruiker"""
if not current_user.is_authenticated:
return jsonify({'error': 'Inloggen vereist'}), 401
try:
data = request.get_json()
anonymous_incidents = data.get('incidents', [])
migrated_count = 0
for incident_data in anonymous_incidents:
# Controleer of incident al bestaat (op basis van timestamp en severity)
timestamp = datetime.fromisoformat(incident_data['timestamp'].replace('Z', '+00:00'))
existing = Incident.query.filter_by(
user_id=current_user.id,
timestamp=timestamp,
severity=incident_data['severity']
).first()
if not existing:
incident = Incident(
user_id=current_user.id,
timestamp=timestamp,
severity=incident_data['severity'],
notes=incident_data.get('notes')
)
db.session.add(incident)
migrated_count += 1
db.session.commit()
return jsonify({
'success': True,
'migrated_count': migrated_count,
'message': f'{migrated_count} incidenten succesvol gemigreerd!'
})
except Exception as e:
db.session.rollback()
return jsonify({'error': 'Fout bij data migratie'}), 500
@bp.route('/export-data')
def export_data():
"""GDPR data export"""
if current_user.is_authenticated:
incidents = Incident.query.filter_by(user_id=current_user.id).all()
data = {
'user': {
'email': current_user.email,
'created_at': current_user.created_at.isoformat()
},
'incidents': [incident.to_dict() for incident in incidents],
'export_date': datetime.utcnow().isoformat(),
'total_incidents': len(incidents)
}
else:
session_id = get_or_create_session_id()
incidents = Incident.query.filter_by(session_id=session_id).all()
data = {
'session_id': session_id,
'incidents': [incident.to_dict() for incident in incidents],
'export_date': datetime.utcnow().isoformat(),
'total_incidents': len(incidents),
'note': 'Anonieme gebruiker data'
}
return jsonify(data)
@bp.route('/delete-account', methods=['POST'])
@login_required
def delete_account():
"""GDPR account verwijdering"""
if request.form.get('confirm') != 'DELETE':
flash('Type "DELETE" om je account te bevestigen', 'error')
return redirect(url_for('main.statistics'))
try:
# Verwijder alle incidenten van de gebruiker
Incident.query.filter_by(user_id=current_user.id).delete()
# Verwijder de gebruiker
user_email = current_user.email
db.session.delete(current_user)
db.session.commit()
logout_user()
flash(f'Account {user_email} en alle gegevens zijn permanent verwijderd', 'info')
return redirect(url_for('main.index'))
except Exception as e:
db.session.rollback()
flash('Fout bij het verwijderen van account', 'error')
return redirect(url_for('main.statistics'))
@bp.route('/privacy')
def privacy():
"""Privacy beleid pagina"""
return render_template('privacy.html')
# Helper functions
def get_or_create_session_id():
"""Haal session ID op of maak een nieuwe voor anonieme gebruikers"""
if 'session_id' not in session:
session['session_id'] = str(uuid.uuid4())
return session['session_id']
def migrate_anonymous_data(user):
"""Probeer data van anonieme gebruiker naar geregistreerde gebruiker te migreren"""
if 'session_id' in session:
session_id = session['session_id']
# Vind incidenten van anonieme sessie
anonymous_incidents = Incident.query.filter_by(session_id=session_id).all()
if anonymous_incidents:
# Migreer naar gebruiker
for incident in anonymous_incidents:
incident.user_id = user.id
incident.session_id = None
db.session.commit()
flash(f'{len(anonymous_incidents)} incidenten zijn gemigreerd naar je account!', 'success')
def calculate_statistics(incidents):
"""Bereken statistieken voor incidents"""
if not incidents:
return {
'total_incidents': 0,
'avg_severity': 0,
'incidents_today': 0,
'incidents_this_week': 0,
'incidents_this_month': 0,
'most_severe': 0,
'trend_data': []
}
now = datetime.utcnow()
today = now.date()
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
# Basis statistieken
total_incidents = len(incidents)
severities = [inc.severity for inc in incidents]
avg_severity = sum(severities) / len(severities) if severities else 0
most_severe = max(severities) if severities else 0
# Tijd-gebaseerde counts
incidents_today = len([inc for inc in incidents if inc.timestamp.date() == today])
incidents_this_week = len([inc for inc in incidents if inc.timestamp >= week_ago])
incidents_this_month = len([inc for inc in incidents if inc.timestamp >= month_ago])
# Trend data (laatste 7 dagen)
trend_data = []
for i in range(7):
date = now - timedelta(days=i)
day_incidents = [inc for inc in incidents if inc.timestamp.date() == date.date()]
trend_data.append({
'date': date.strftime('%m/%d'),
'count': len(day_incidents),
'avg_severity': sum(inc.severity for inc in day_incidents) / len(day_incidents) if day_incidents else 0
})
trend_data.reverse() # Chronologische volgorde
return {
'total_incidents': total_incidents,
'avg_severity': round(avg_severity, 1),
'incidents_today': incidents_today,
'incidents_this_week': incidents_this_week,
'incidents_this_month': incidents_this_month,
'most_severe': most_severe,
'trend_data': trend_data
}

433
app/static/css/style.css Normal file
View file

@ -0,0 +1,433 @@
/* Mobile-first CSS voor Snauw Counter */
/* Reset en basis styling */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
min-height: 100vh;
}
/* Container */
.container {
max-width: 100%;
padding: 1rem;
margin: 0 auto;
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
/* Banner Section */
.banner-section {
text-align: center;
margin-bottom: 1rem;
}
.banner-image {
display: inline-block;
max-width: 100%;
margin: 0 auto;
}
.snauw-banner {
width: 100%;
max-width: 600px;
height: auto;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
background: #2c3e50;
}
@media (max-width: 768px) {
.snauw-banner {
max-width: 95%;
}
.banner-section {
margin-bottom: 0.5rem;
}
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
text-align: center;
}
.nav {
display: flex;
justify-content: center;
margin-top: 1rem;
gap: 1rem;
}
.nav a {
color: rgba(255,255,255,0.9);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 20px;
transition: all 0.3s ease;
font-size: 0.9rem;
}
.nav a:hover,
.nav a.active {
background: rgba(255,255,255,0.2);
color: white;
}
/* Cards */
.card {
background: white;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 2px 20px rgba(0,0,0,0.08);
border: 1px solid #e9ecef;
}
/* Buttons */
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: all 0.3s ease;
width: 100%;
margin-bottom: 0.5rem;
}
.btn:hover {
background: #5a67d8;
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #6c757d;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-danger {
background: #dc3545;
}
.btn-danger:hover {
background: #c82333;
}
.btn-success {
background: #28a745;
}
.btn-success:hover {
background: #218838;
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #495057;
}
input[type="email"],
input[type="password"],
input[type="number"],
input[type="datetime-local"],
textarea,
select {
width: 100%;
padding: 0.75rem;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s ease;
background: white;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
textarea {
min-height: 100px;
resize: vertical;
}
/* Severity selector - speciale styling voor Snauw-index */
.severity-selector {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
margin: 1rem 0;
}
.severity-btn {
aspect-ratio: 1;
border: 2px solid #e9ecef;
background: white;
border-radius: 8px;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.severity-btn:hover {
border-color: #667eea;
background: #f8f9ff;
}
.severity-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
/* Snauw-index specific styling */
.snauw-phases div {
margin-bottom: 0.75rem;
padding: 0.5rem;
border-left: 3px solid #e9ecef;
padding-left: 0.75rem;
}
.snauw-phases div:nth-child(1) { border-left-color: #28a745; } /* Fase 1 - Groen */
.snauw-phases div:nth-child(2) { border-left-color: #ffc107; } /* Fase 2 - Geel */
.snauw-phases div:nth-child(3) { border-left-color: #fd7e14; } /* Fase 3 - Oranje */
.snauw-phases div:nth-child(4) { border-left-color: #dc3545; } /* Fase 4 - Rood */
.snauw-phases div:nth-child(5) { border-left-color: #6f42c1; } /* Fase 5 - Paars */
/* Color coding for severity buttons */
.severity-btn[data-severity="1"] { border-color: #28a745; color: #28a745; }
.severity-btn[data-severity="2"] { border-color: #ffc107; color: #ffc107; }
.severity-btn[data-severity="3"] { border-color: #fd7e14; color: #fd7e14; }
.severity-btn[data-severity="4"] { border-color: #dc3545; color: #dc3545; }
.severity-btn[data-severity="5"] { border-color: #6f42c1; color: #6f42c1; }
.severity-btn[data-severity="1"]:hover,
.severity-btn[data-severity="1"].active {
background: #28a745;
border-color: #28a745;
color: white;
}
.severity-btn[data-severity="2"]:hover,
.severity-btn[data-severity="2"].active {
background: #ffc107;
border-color: #ffc107;
color: white;
}
.severity-btn[data-severity="3"]:hover,
.severity-btn[data-severity="3"].active {
background: #fd7e14;
border-color: #fd7e14;
color: white;
}
.severity-btn[data-severity="4"]:hover,
.severity-btn[data-severity="4"].active {
background: #dc3545;
border-color: #dc3545;
color: white;
}
.severity-btn[data-severity="5"]:hover,
.severity-btn[data-severity="5"].active {
background: #6f42c1;
border-color: #6f42c1;
color: white;
}
/* Statistieken */
.stats-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1.5rem;
border-radius: 12px;
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
display: block;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
}
/* Chart container */
.chart-container {
position: relative;
height: 300px;
margin: 1rem 0;
}
/* Flash messages */
.flash-messages {
margin-bottom: 1rem;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
/* Privacy notice */
.privacy-notice {
background: #fff3cd;
color: #856404;
padding: 1rem;
border-radius: 8px;
border: 1px solid #ffeaa7;
margin-bottom: 1rem;
font-size: 0.9rem;
}
/* Quick add incident floating button */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 60px;
height: 60px;
border-radius: 50%;
background: #667eea;
color: white;
border: none;
font-size: 1.5rem;
cursor: pointer;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
transition: all 0.3s ease;
z-index: 1000;
}
.fab:hover {
background: #5a67d8;
transform: scale(1.1);
}
/* Responsive design - Tablet */
@media (min-width: 768px) {
.container {
max-width: 768px;
padding: 2rem;
}
.header h1 {
font-size: 2rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.severity-selector {
grid-template-columns: repeat(5, 1fr);
gap: 1rem;
}
}
/* Desktop */
@media (min-width: 1024px) {
.container {
max-width: 1024px;
}
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
.btn {
width: auto;
margin-right: 0.5rem;
}
}
/* Animations */
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
#header-counter, #counter-digit-1, #counter-digit-2, #counter-digit-3 {
transition: all 0.3s ease;
font-family: 'Courier New', monospace;
text-shadow: 0 0 10px rgba(231, 76, 60, 0.5);
}
#header-counter.updated,
#counter-digit-1.updated,
#counter-digit-2.updated,
#counter-digit-3.updated {
animation: pulse 1s ease-in-out 3;
color: #f39c12 !important;
}

View file

@ -0,0 +1 @@
Not Found

BIN
app/static/img/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

573
app/static/js/app.js Normal file
View file

@ -0,0 +1,573 @@
// Snauw Counter - Frontend JavaScript
// Global variables
let selectedSeverity = null;
// Fast datetime initialization - runs as soon as script loads
function fastDateTimeInit() {
const dateTimeInput = document.getElementById('incident_time');
if (dateTimeInput) {
const now = new Date();
const formattedDateTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}T${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
dateTimeInput.value = formattedDateTime;
}
}
// Try to initialize datetime immediately if elements exist
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fastDateTimeInit);
} else {
fastDateTimeInit();
}
// Initialize app when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
// Initialize date/time FIRST - before any user interaction
initializeDateTime();
trackDateTimeModification();
// Then initialize other components
initializeSeveritySelector();
initializeAnonymousUserSupport();
loadChart();
updateHeaderCounter();
// Hide privacy notice als gebruiker het al eerder heeft weggedaan
if (localStorage.getItem('privacy-notice-hidden')) {
const notice = document.getElementById('privacy-notice');
if (notice) notice.style.display = 'none';
}
});
// Initialize date/time fields with current date and time
function initializeDateTime() {
const dateTimeInput = document.getElementById('incident_time');
if (dateTimeInput) {
// Get current date and time
const now = new Date();
// Format to datetime-local format (YYYY-MM-DDTHH:mm)
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const formattedDateTime = `${year}-${month}-${day}T${hours}:${minutes}`;
dateTimeInput.value = formattedDateTime;
// Add helper text
const helpText = dateTimeInput.nextElementSibling;
if (helpText && helpText.tagName === 'SMALL') {
helpText.innerHTML = `<span style="color: #28a745;">✓ Automatisch ingevuld: ${formatDisplayTime(now)}</span>`;
}
}
}
// Format time for display
function formatDisplayTime(date) {
return date.toLocaleString('nl-NL', {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
// Update date/time when page becomes visible (for accuracy)
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
// Only update if the field hasn't been manually changed
const dateTimeInput = document.getElementById('incident_time');
if (dateTimeInput && !dateTimeInput.dataset.userModified) {
initializeDateTime();
}
}
});
// Track if user manually modifies the datetime field
function trackDateTimeModification() {
const dateTimeInput = document.getElementById('incident_time');
if (dateTimeInput) {
dateTimeInput.addEventListener('input', function() {
this.dataset.userModified = 'true';
const helpText = this.nextElementSibling;
if (helpText && helpText.tagName === 'SMALL') {
helpText.innerHTML = '<span style="color: #6c757d;">Handmatig aangepast</span>';
}
});
// Add reset button
const resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.className = 'btn btn-secondary';
resetBtn.style.marginLeft = '0.5rem';
resetBtn.style.padding = '0.25rem 0.5rem';
resetBtn.style.fontSize = '0.8rem';
resetBtn.innerHTML = '🔄 Nu';
resetBtn.title = 'Reset naar huidige tijd';
resetBtn.onclick = function() {
delete dateTimeInput.dataset.userModified;
initializeDateTime();
};
// Only add reset button if it doesn't exist yet
if (!dateTimeInput.parentNode.querySelector('.btn.btn-secondary')) {
dateTimeInput.parentNode.insertBefore(resetBtn, dateTimeInput.nextSibling);
}
}
}
// Update date/time when page becomes visible (for accuracy)
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
// Only update if the field hasn't been manually changed
const dateTimeInput = document.getElementById('incident_time');
if (dateTimeInput && !dateTimeInput.dataset.userModified) {
initializeDateTime();
}
}
});
// Quick add incident functionality
function quickAddIncident() {
// Check if we're already on add incident page
if (window.location.pathname.includes('/add')) {
return;
}
// For anonymous users, show quick add modal
if (!document.body.dataset.isAuthenticated) {
showQuickAddModal();
} else {
// Redirect to add incident page
window.location.href = '/add-incident';
}
}
// Show quick add modal for anonymous users
function showQuickAddModal() {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h3>🔥 Snel incident toevoegen</h3>
<button onclick="closeModal()" class="close-btn"></button>
</div>
<div class="modal-body">
<form onsubmit="submitQuickIncident(event)">
<div class="form-group">
<label>Snauw-index (1-5):</label>
<div class="severity-selector">
${getSnauwIndexButtons()}
</div>
</div>
<div class="form-group">
<label for="quick-notes">Opmerkingen (optioneel):</label>
<textarea id="quick-notes" placeholder="Wat gebeurde er?"></textarea>
</div>
<button type="submit" class="btn">📝 Opslaan</button>
</form>
</div>
</div>
`;
document.body.appendChild(modal);
// Initialize severity selector for modal
initializeSeveritySelector();
// Add modal styles
const style = document.createElement('style');
style.textContent = `
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.modal-content {
background: white;
border-radius: 12px;
max-width: 500px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e9ecef;
}
.modal-body {
padding: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6c757d;
}
`;
document.head.appendChild(style);
}
// Initialize severity selector buttons
function initializeSeveritySelector() {
const severityButtons = document.querySelectorAll('.severity-btn');
const severityInput = document.getElementById('severity-input');
severityButtons.forEach(button => {
button.addEventListener('click', function() {
// Remove active class from all buttons
severityButtons.forEach(btn => btn.classList.remove('active'));
// Add active class to clicked button
this.classList.add('active');
// Update selected severity
selectedSeverity = parseInt(this.dataset.severity);
// Update hidden input value if it exists
if (severityInput) {
severityInput.value = selectedSeverity;
}
console.log(`Selected severity: ${selectedSeverity}`);
});
});
}
// Update header counter with total incidents
function updateHeaderCounter() {
const counterDigit1 = document.getElementById('counter-digit-1');
const counterDigit2 = document.getElementById('counter-digit-2');
const counterDigit3 = document.getElementById('counter-digit-3');
// Also update the old header-counter for backwards compatibility
const counterElement = document.getElementById('header-counter');
if (!counterDigit1 && !counterElement) return;
let totalIncidents = 0;
if (document.body.dataset.isAuthenticated === 'true') {
// For authenticated users - would normally fetch from API
// For now, we'll use a placeholder
totalIncidents = 0;
} else {
// For anonymous users - use localStorage
const incidents = JSON.parse(localStorage.getItem('snauw_incidents') || '[]');
totalIncidents = incidents.length;
}
// Format number with leading zeros
const formattedCount = totalIncidents.toString().padStart(3, '0');
// Update individual digits if they exist (new banner)
if (counterDigit1 && counterDigit2 && counterDigit3) {
counterDigit1.textContent = formattedCount[0];
counterDigit2.textContent = formattedCount[1];
counterDigit3.textContent = formattedCount[2];
// Add pulsing animation for new incidents
if (totalIncidents > 0) {
[counterDigit1, counterDigit2, counterDigit3].forEach(digit => {
digit.style.animation = 'pulse 2s ease-in-out infinite';
});
}
}
// Update old counter element if it exists (backwards compatibility)
if (counterElement) {
counterElement.textContent = formattedCount;
// Add pulsing animation for new incidents
if (totalIncidents > 0) {
counterElement.style.animation = 'pulse 2s ease-in-out infinite';
}
}
}
// Generate Snauw-index buttons with descriptions
function getSnauwIndexButtons() {
const descriptions = [
'Lichte correctie',
'Geprikkelde waarschuwing',
'Openlijke snauw',
'Bijtende aanval',
'Explosieve uitval'
];
return [...Array(5)].map((_, i) => `
<button type="button" class="severity-btn" data-severity="${i+1}" title="Fase ${i+1}: ${descriptions[i]}">
${i+1}
</button>
`).join('');
}
// Close modal
function closeModal() {
const modal = document.querySelector('.modal');
if (modal) {
modal.remove();
}
}
// Submit quick incident for anonymous users
async function submitQuickIncident(event) {
event.preventDefault();
if (!selectedSeverity) {
alert('Selecteer een Snauw-index');
return;
}
const notes = document.getElementById('quick-notes').value;
const now = new Date();
const incident = {
severity: selectedSeverity,
notes: notes,
timestamp: now.toISOString()
};
// Store in localStorage for anonymous users
let incidents = JSON.parse(localStorage.getItem('snauw_incidents') || '[]');
incident.id = Date.now(); // Simple ID for local storage
incidents.push(incident);
localStorage.setItem('snauw_incidents', JSON.stringify(incidents));
// Update header counter
updateHeaderCounter();
// Add animation to counter
const counterElement = document.getElementById('header-counter');
if (counterElement) {
counterElement.classList.add('updated');
setTimeout(() => counterElement.classList.remove('updated'), 3000);
}
// Show success message
showNotification('✅ Incident opgeslagen!', 'success');
// Close modal
closeModal();
// Refresh statistics if on stats page
if (window.location.pathname.includes('statistics')) {
location.reload();
}
}
// Show notification
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `alert alert-${type}`;
notification.textContent = message;
notification.style.position = 'fixed';
notification.style.top = '2rem';
notification.style.right = '2rem';
notification.style.zIndex = '3000';
notification.style.minWidth = '250px';
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
// Anonymous user support
function initializeAnonymousUserSupport() {
// Check if user has incidents in localStorage
const localIncidents = localStorage.getItem('snauw_incidents');
if (localIncidents && !document.body.dataset.isAuthenticated) {
const incidents = JSON.parse(localIncidents);
if (incidents.length > 0) {
showMigrationOffer();
}
}
}
// Show migration offer to anonymous users with data
function showMigrationOffer() {
const incidents = JSON.parse(localStorage.getItem('snauw_incidents') || '[]');
if (incidents.length < 3) return; // Only show after a few incidents
const lastOffer = localStorage.getItem('migration-offer-shown');
const oneWeekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
if (lastOffer && parseInt(lastOffer) > oneWeekAgo) return; // Don't show too often
const banner = document.createElement('div');
banner.className = 'migration-banner';
banner.innerHTML = `
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
<strong>💾 Je hebt ${incidents.length} incidenten opgeslagen!</strong><br>
Maak een account om je gegevens veilig te bewaren en op meerdere apparaten te gebruiken.
<div style="margin-top: 0.5rem;">
<a href="/register" class="btn btn-success" style="display: inline-block; margin-right: 0.5rem;">Account maken</a>
<button onclick="hideMigrationBanner()" class="btn btn-secondary" style="display: inline-block;">Later</button>
</div>
</div>
`;
const main = document.querySelector('main.container');
main.insertBefore(banner, main.firstChild);
localStorage.setItem('migration-offer-shown', Date.now().toString());
}
// Hide migration banner
function hideMigrationBanner() {
const banner = document.querySelector('.migration-banner');
if (banner) banner.remove();
}
// Hide privacy notice
function hidePrivacyNotice() {
const notice = document.getElementById('privacy-notice');
if (notice) {
notice.style.display = 'none';
localStorage.setItem('privacy-notice-hidden', 'true');
}
}
// Chart functionality
function loadChart() {
const chartCanvas = document.getElementById('incidentsChart');
if (!chartCanvas) return;
// For anonymous users, use localStorage data
if (!document.body.dataset.isAuthenticated) {
loadAnonymousChart();
} else {
loadUserChart();
}
}
// Load chart for anonymous users
function loadAnonymousChart() {
const incidents = JSON.parse(localStorage.getItem('snauw_incidents') || '[]');
// Group by date
const dateGroups = {};
incidents.forEach(incident => {
const date = new Date(incident.timestamp).toDateString();
if (!dateGroups[date]) {
dateGroups[date] = [];
}
dateGroups[date].push(incident);
});
// Prepare chart data
const dates = Object.keys(dateGroups).slice(-7); // Last 7 days
const counts = dates.map(date => dateGroups[date].length);
const averages = dates.map(date => {
const dayIncidents = dateGroups[date];
const sum = dayIncidents.reduce((acc, inc) => acc + inc.severity, 0);
return sum / dayIncidents.length || 0;
});
renderChart(dates, counts, averages);
}
// Load chart for authenticated users (would fetch from server)
function loadUserChart() {
// This would make an API call to get user's incident data
// For now, placeholder
renderChart([], [], []);
}
// Render chart using Chart.js (simplified version without external dependency)
function renderChart(dates, counts, averages) {
const canvas = document.getElementById('incidentsChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Simple bar chart implementation
// In a real implementation, you'd use Chart.js library
ctx.fillStyle = '#667eea';
ctx.fillRect(10, 10, 50, 100);
ctx.fillText('Chart placeholder - Use Chart.js in production', 10, 130);
}
// Form validation
function validateIncidentForm() {
const severity = document.getElementById('severity-input');
if (!severity || !severity.value) {
alert('Selecteer een Snauw-index');
return false;
}
const severityValue = parseInt(severity.value);
if (severityValue < 1 || severityValue > 5) {
alert('Snauw-index moet tussen 1 en 5 zijn');
return false;
}
return true;
}
// Export data (for GDPR compliance)
function exportData() {
let data;
if (document.body.dataset.isAuthenticated) {
// Would fetch from server for authenticated users
alert('Export functionaliteit wordt nog geïmplementeerd voor geregistreerde gebruikers');
return;
} else {
// Export localStorage data for anonymous users
data = {
incidents: JSON.parse(localStorage.getItem('snauw_incidents') || '[]'),
exportDate: new Date().toISOString(),
type: 'anonymous_user_data'
};
}
const dataStr = JSON.stringify(data, null, 2);
const dataBlob = new Blob([dataStr], {type: 'application/json'});
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `snauw_counter_export_${new Date().toISOString().split('T')[0]}.json`;
link.click();
}
// Delete all data (for GDPR compliance)
function deleteAllData() {
if (!confirm('Weet je zeker dat je alle gegevens wilt verwijderen? Dit kan niet ongedaan worden gemaakt.')) {
return;
}
if (document.body.dataset.isAuthenticated) {
// Would make API call for authenticated users
alert('Data verwijdering voor geregistreerde gebruikers wordt nog geïmplementeerd');
return;
} else {
// Clear localStorage for anonymous users
localStorage.removeItem('snauw_incidents');
localStorage.removeItem('privacy-notice-hidden');
localStorage.removeItem('migration-offer-shown');
showNotification('✅ Alle lokale gegevens zijn verwijderd', 'success');
setTimeout(() => {
location.reload();
}, 2000);
}
}

24
app/static/manifest.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "Snauw Counter",
"short_name": "SnauwCounter",
"description": "Bijhouden van onvriendelijk gedrag - privé en veilig",
"start_url": "/",
"display": "standalone",
"background_color": "#667eea",
"theme_color": "#667eea",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%23667eea' width='100' height='100' rx='20'/><text y='70' x='50' text-anchor='middle' font-size='60'>📊</text></svg>",
"sizes": "192x192",
"type": "image/svg+xml"
},
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%23667eea' width='100' height='100' rx='20'/><text y='70' x='50' text-anchor='middle' font-size='60'>📊</text></svg>",
"sizes": "512x512",
"type": "image/svg+xml"
}
],
"orientation": "portrait",
"categories": ["productivity", "lifestyle"],
"lang": "nl"
}

View file

@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}Incident Toevoegen - Snauw Counter{% endblock %}
{% block content %}
<div class="card">
<h2>📝 Incident Registreren</h2>
<p>Registreer een nieuw incident met alle details.</p>
<form action="{{ url_for('main.add_incident') }}" method="POST" onsubmit="return validateIncidentForm()">
<div class="form-group">
<label>Snauw-index (1-5 schaal) *:</label>
<div class="severity-selector">
{% set snauw_descriptions = [
'Lichte correctie',
'Geprikkelde waarschuwing',
'Openlijke snauw',
'Bijtende aanval',
'Explosieve uitval'
] %}
{% for i in range(1, 6) %}
<button type="button" class="severity-btn" data-severity="{{ i }}" title="Fase {{ i }}: {{ snauw_descriptions[i-1] }}">
{{ i }}
</button>
{% endfor %}
</div>
<input type="hidden" id="severity-input" name="severity" required>
<!-- Snauw-index uitleg -->
<div class="snauw-index-info" style="margin-top: 1rem; font-size: 0.9rem; color: #6c757d;">
<details>
<summary style="cursor: pointer; font-weight: 500;">📖 Uitleg Snauw-index</summary>
<div style="margin-top: 0.5rem; padding: 1rem; background: #f8f9fa; border-radius: 8px;">
<div class="snauw-phases">
<div style="margin-bottom: 0.5rem;"><strong>Fase 1 - Lichte correctie:</strong><br><small>Licht geïrriteerd, kortaf of subtiel sarcastisch. Nog beheerst en goed bij te sturen.</small></div>
<div style="margin-bottom: 0.5rem;"><strong>Fase 2 - Geprikkelde waarschuwing:</strong><br><small>Irritatie wordt uitgesproken, toon scherper. Duidelijk signaal dat grenzen naderen.</small></div>
<div style="margin-bottom: 0.5rem;"><strong>Fase 3 - Openlijke snauw:</strong><br><small>Emotie overheerst, stem verheft zich. Verwijtend. Gesprek wordt gespannen.</small></div>
<div style="margin-bottom: 0.5rem;"><strong>Fase 4 - Bijtende aanval:</strong><br><small>Persoonlijk, cynisch en scherp. Kans op defensief of escalatie.</small></div>
<div style="margin-bottom: 0.5rem;"><strong>Fase 5 - Explosieve uitval:</strong><br><small>Volledige ontlading, hard en fel. Communicatie niet meer mogelijk.</small></div>
</div>
</div>
</details>
</div>
</div>
<div class="form-group">
<label for="incident_time">Tijdstip incident:</label>
<input type="datetime-local" id="incident_time" name="incident_time">
<script>((f)=>f())(()=>{const d=new Date(),i=document.getElementById('incident_time');if(i)i.value=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`})</script>
<small style="color: #6c757d;">Automatisch ingevuld, pas aan indien nodig</small>
</div>
<div class="form-group">
<label for="notes">Beschrijving (optioneel):</label>
<textarea id="notes" name="notes" rows="4"
placeholder="Wat gebeurde er precies? Wat was de context? Hoe reageerde je?..."></textarea>
<small style="color: #6c757d;">Deze informatie kan helpen bij het herkennen van patronen</small>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-success">💾 Opslaan</button>
<a href="{{ url_for('main.index') }}" class="btn btn-secondary">❌ Annuleren</a>
</div>
</form>
</div>
<div class="card" style="background: #f8f9fa; border: 1px dashed #dee2e6;">
<h4>💡 Tips voor nauwkeurige registratie</h4>
<ul style="margin-left: 1rem; color: #495057;">
<li><strong>Snauw-index:</strong> Gebruik de 5-punts schaal consistent</li>
<li><strong>Timing:</strong> Registreer zo snel mogelijk na het incident</li>
<li><strong>Context:</strong> Noteer relevante omstandigheden (stress, vermoeidheid, etc.)</li>
<li><strong>Objectiviteit:</strong> Beschrijf wat er gebeurde, niet hoe je je voelde</li>
</ul>
</div>
{% endblock %}
{% block scripts %}
<script>
// Auto-focus on severity selector for mobile
document.addEventListener('DOMContentLoaded', function() {
// Scroll to severity selector for mobile
if (window.innerWidth <= 768) {
const severitySelector = document.querySelector('.severity-selector');
setTimeout(() => {
severitySelector.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 300);
}
});
// Quick keyboard shortcuts for desktop
document.addEventListener('keydown', function(e) {
// Numbers 1-5 for Snauw-index
if (e.key >= '1' && e.key <= '5') {
const severity = parseInt(e.key);
const btn = document.querySelector(`[data-severity="${severity}"]`);
if (btn) {
btn.click();
e.preventDefault();
}
}
});
</script>
{% endblock %}

225
app/templates/base.html Normal file
View file

@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Snauw Counter{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<meta name="description" content="Bijhouden van onvriendelijk gedrag - privé en veilig">
<meta name="theme-color" content="#667eea">
<!-- PWA metadata -->
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Snauw Counter">
{% block head %}{% endblock %}
</head>
<body>
<header class="header">
<div class="container">
<!-- Snauw Counter Banner -->
<div class="banner-section">
<div class="banner-image">
<svg class="snauw-banner" viewBox="0 0 1133 576" xmlns="http://www.w3.org/2000/svg">
<!-- Metal frame background -->
<defs>
<linearGradient id="metalGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4a4a4a;stop-opacity:1" />
<stop offset="50%" style="stop-color:#2c2c2c;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1a1a1a;stop-opacity:1" />
</linearGradient>
<radialGradient id="boltGrad" cx="50%" cy="50%" r="50%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="20%" style="stop-color:#e0e0e0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#a0a0a0;stop-opacity:1" />
</radialGradient>
</defs>
<!-- Background frame -->
<rect x="20" y="20" width="1093" height="536" fill="url(#metalGrad)" stroke="#666" stroke-width="4" rx="15"/>
<!-- Corner bolts -->
<circle cx="45" cy="45" r="12" fill="url(#boltGrad)" stroke="#333" stroke-width="2"/>
<circle cx="45" cy="45" r="8" fill="none" stroke="#222" stroke-width="1"/>
<circle cx="45" cy="45" r="4" fill="none" stroke="#222" stroke-width="1"/>
<circle cx="1088" cy="45" r="12" fill="url(#boltGrad)" stroke="#333" stroke-width="2"/>
<circle cx="1088" cy="45" r="8" fill="none" stroke="#222" stroke-width="1"/>
<circle cx="1088" cy="45" r="4" fill="none" stroke="#222" stroke-width="1"/>
<circle cx="45" cy="531" r="12" fill="url(#boltGrad)" stroke="#333" stroke-width="2"/>
<circle cx="45" cy="531" r="8" fill="none" stroke="#222" stroke-width="1"/>
<circle cx="45" cy="531" r="4" fill="none" stroke="#222" stroke-width="1"/>
<circle cx="1088" cy="531" r="12" fill="url(#boltGrad)" stroke="#333" stroke-width="2"/>
<circle cx="1088" cy="531" r="8" fill="none" stroke="#222" stroke-width="1"/>
<circle cx="1088" cy="531" r="4" fill="none" stroke="#222" stroke-width="1"/>
<!-- Dark stormy background -->
<rect x="40" y="40" width="1053" height="496" fill="#1a1a2e" rx="10"/>
<!-- Storm clouds -->
<ellipse cx="150" cy="100" rx="60" ry="30" fill="#3d3d5c" opacity="0.8"/>
<ellipse cx="180" cy="85" rx="45" ry="25" fill="#4a4a6b" opacity="0.7"/>
<ellipse cx="950" cy="120" rx="70" ry="35" fill="#3d3d5c" opacity="0.8"/>
<ellipse cx="980" cy="105" rx="50" ry="28" fill="#4a4a6b" opacity="0.7"/>
<ellipse cx="500" cy="80" rx="40" ry="20" fill="#4a4a6b" opacity="0.6"/>
<!-- Lightning effects -->
<path d="M 120 150 L 140 180 L 130 180 L 150 220 L 135 200 L 145 200 L 125 170 Z" fill="#f1c40f" opacity="0.9"/>
<path d="M 980 160 L 1000 190 L 990 190 L 1010 230 L 995 210 L 1005 210 L 985 180 Z" fill="#f1c40f" opacity="0.9"/>
<!-- Swear symbols -->
<text x="180" y="250" fill="#e74c3c" font-size="32" font-weight="bold" font-family="Arial, sans-serif">@#$!?!</text>
<!-- Main title banner -->
<path d="M 300 180 L 833 180 L 853 220 L 833 260 L 300 260 L 280 220 Z" fill="#d35400" stroke="#a04000" stroke-width="3"/>
<path d="M 305 185 L 828 185 L 845 220 L 828 255 L 305 255 L 285 220 Z" fill="#e67e22"/>
<text x="566" y="235" text-anchor="middle" fill="white" font-size="42" font-weight="bold" font-family="Arial, sans-serif" text-shadow="2px 2px 4px rgba(0,0,0,0.5)">SNAUW COUNTER!</text>
<!-- Counter display -->
<rect x="410" y="350" width="313" height="80" fill="#2c3e50" stroke="#ecf0f1" stroke-width="4" rx="8"/>
<rect x="418" y="358" width="297" height="64" fill="#34495e" rx="4"/>
<text x="440" y="385" fill="#bdc3c7" font-size="24" font-weight="bold" font-family="Arial, sans-serif">STAND:</text>
<!-- Individual counter digits -->
<rect x="520" y="368" width="50" height="48" fill="#1a1a1a" stroke="#ecf0f1" stroke-width="2" rx="4"/>
<text x="545" y="402" text-anchor="middle" fill="#e74c3c" font-size="36" font-weight="bold" id="counter-digit-1">0</text>
<rect x="580" y="368" width="50" height="48" fill="#1a1a1a" stroke="#ecf0f1" stroke-width="2" rx="4"/>
<text x="605" y="402" text-anchor="middle" fill="#e74c3c" font-size="36" font-weight="bold" id="counter-digit-2">1</text>
<rect x="640" y="368" width="50" height="48" fill="#1a1a1a" stroke="#ecf0f1" stroke-width="2" rx="4"/>
<text x="665" y="402" text-anchor="middle" fill="#e74c3c" font-size="36" font-weight="bold" id="counter-digit-3">5</text>
<!-- Woman (angry, left side) -->
<!-- Hair -->
<path d="M 150 280 Q 120 270 110 300 Q 105 330 115 350 Q 125 360 140 365 Q 160 370 180 365 Q 200 360 210 350 Q 220 330 215 300 Q 205 270 175 280" fill="#8B4513" stroke="#654321" stroke-width="2"/>
<!-- Face -->
<ellipse cx="162" cy="320" rx="35" ry="40" fill="#FDBCB4" stroke="#E6A8A0" stroke-width="2"/>
<!-- Eyes (angry) -->
<ellipse cx="150" cy="310" rx="8" ry="6" fill="white"/>
<ellipse cx="174" cy="310" rx="8" ry="6" fill="white"/>
<circle cx="150" cy="310" r="4" fill="#2c3e50"/>
<circle cx="174" cy="310" r="4" fill="#2c3e50"/>
<!-- Angry eyebrows -->
<path d="M 140 300 L 158 305" stroke="#8B4513" stroke-width="3" stroke-linecap="round"/>
<path d="M 166 305 L 184 300" stroke="#8B4513" stroke-width="3" stroke-linecap="round"/>
<!-- Nose -->
<ellipse cx="162" cy="325" rx="3" ry="5" fill="#E6A8A0"/>
<!-- Mouth (shouting) -->
<ellipse cx="162" cy="340" rx="12" ry="8" fill="#8B0000"/>
<ellipse cx="162" cy="340" rx="10" ry="6" fill="#000000"/>
<!-- Pointing finger -->
<rect x="200" y="335" width="25" height="8" fill="#FDBCB4" rx="4"/>
<ellipse cx="228" cy="339" rx="6" ry="4" fill="#FDBCB4"/>
<!-- Arm -->
<ellipse cx="190" cy="360" rx="15" ry="30" fill="#2D4A22" transform="rotate(20 190 360)"/>
<!-- Body -->
<ellipse cx="162" cy="420" rx="40" ry="60" fill="#2D4A22"/>
<!-- Man (worried, right side) -->
<!-- Hair -->
<path d="M 920 280 Q 890 270 880 300 Q 875 320 885 340 Q 900 355 920 360 Q 945 365 970 360 Q 990 355 1005 340 Q 1015 320 1010 300 Q 1000 270 970 280" fill="#D2691E" stroke="#A0522D" stroke-width="2"/>
<!-- Face -->
<ellipse cx="950" cy="320" rx="40" ry="45" fill="#FDBCB4" stroke="#E6A8A0" stroke-width="2"/>
<!-- Eyes (worried) -->
<ellipse cx="935" cy="310" rx="9" ry="7" fill="white"/>
<ellipse cx="965" cy="310" rx="9" ry="7" fill="white"/>
<circle cx="935" cy="312" r="4" fill="#2c3e50"/>
<circle cx="965" cy="312" r="4" fill="#2c3e50"/>
<!-- Worried eyebrows -->
<path d="M 925 300 Q 935 295 945 300" stroke="#D2691E" stroke-width="3" stroke-linecap="round"/>
<path d="M 955 300 Q 965 295 975 300" stroke="#D2691E" stroke-width="3" stroke-linecap="round"/>
<!-- Nose -->
<ellipse cx="950" cy="325" rx="3" ry="5" fill="#E6A8A0"/>
<!-- Mouth (worried) -->
<path d="M 935 340 Q 950 350 965 340" stroke="#8B0000" stroke-width="3" fill="none" stroke-linecap="round"/>
<!-- Hand on head -->
<ellipse cx="920" cy="300" rx="8" ry="12" fill="#FDBCB4" transform="rotate(-30 920 300)"/>
<rect x="910" y="290" width="20" height="8" fill="#FDBCB4" rx="4" transform="rotate(-30 920 294)"/>
<!-- Arm -->
<ellipse cx="925" cy="350" rx="12" ry="25" fill="#1E3A8A" transform="rotate(-20 925 350)"/>
<!-- Body/shirt -->
<ellipse cx="950" cy="420" rx="45" ry="65" fill="#1E3A8A"/>
<!-- Update header counter with JavaScript hook -->
<text x="566" y="500" text-anchor="middle" fill="transparent" font-size="1" id="header-counter">015</text>
</svg>
</div>
</div>
<nav class="nav">
<a href="{{ url_for('main.index') }}" {% if request.endpoint == 'main.index' %}class="active"{% endif %}>
🏠 Home
</a>
<a href="{{ url_for('main.statistics') }}" {% if request.endpoint == 'main.statistics' %}class="active"{% endif %}>
📈 Statistieken
</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('main.logout') }}">🚪 Uitloggen</a>
{% else %}
<a href="{{ url_for('main.login') }}" {% if request.endpoint == 'main.login' %}class="active"{% endif %}>
🔐 Inloggen
</a>
{% endif %}
</nav>
</div>
</header>
<main class="container">
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else category }}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Privacy notice voor anonieme gebruikers -->
{% if not current_user.is_authenticated %}
<div class="privacy-notice" id="privacy-notice">
<strong>🔒 Privacy:</strong> Als anonieme gebruiker worden je gegevens alleen lokaal opgeslagen in je browser.
<a href="{{ url_for('main.register') }}" style="color: #856404; text-decoration: underline;">Maak een account</a>
om je gegevens veilig op te slaan.
<button onclick="hidePrivacyNotice()" style="float: right; background: none; border: none; color: #856404; cursor: pointer;"></button>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<!-- Floating Action Button voor snelle incident toevoeging -->
{% if request.endpoint != 'main.add_incident' %}
<button class="fab" onclick="quickAddIncident()" title="Snel incident toevoegen">+</button>
{% endif %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

138
app/templates/index.html Normal file
View file

@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}Home - Snauw Counter{% endblock %}
{% block content %}
<div class="card">
<h2>🔥 Nieuw incident registreren</h2>
<p>Houd bij wanneer er je partner weer eens tegen je snauwt!.</p>
<form action="{{ url_for('main.add_incident') }}" method="POST" onsubmit="return validateIncidentForm()">
<div class="form-group">
<label for="severity">Snauw-index (1-5 schaal):</label>
<div class="severity-selector">
{% set snauw_descriptions = [
'Lichte correctie',
'Geprikkelde waarschuwing',
'Openlijke snauw',
'Bijtende aanval',
'Explosieve uitval'
] %}
{% for i in range(1, 6) %}
<button type="button" class="severity-btn" data-severity="{{ i }}" title="Fase {{ i }}: {{ snauw_descriptions[i-1] }}">
{{ i }}
</button>
{% endfor %}
</div>
<input type="hidden" id="severity-input" name="severity" required>
<!-- Snauw-index uitleg -->
<div class="snauw-index-info" style="margin-top: 1rem; font-size: 0.9rem; color: #6c757d;">
<details>
<summary style="cursor: pointer; font-weight: 500;">📖 Uitleg Snauw-index</summary>
<div style="margin-top: 0.5rem; padding: 1rem; background: #f8f9fa; border-radius: 8px;">
<div class="snauw-phases">
<div><strong>Fase 1 - Lichte correctie:</strong><br>Licht geïrriteerd, kortaf of subtiel sarcastisch. Nog beheerst en goed bij te sturen.</div>
<div><strong>Fase 2 - Geprikkelde waarschuwing:</strong><br>Irritatie wordt uitgesproken, toon scherper. Duidelijk signaal dat grenzen naderen.</div>
<div><strong>Fase 3 - Openlijke snauw:</strong><br>Emotie overheerst, stem verheft zich. Verwijtend. Gesprek wordt gespannen.</div>
<div><strong>Fase 4 - Bijtende aanval:</strong><br>Persoonlijk, cynisch en scherp. Kans op defensief of escalatie.</div>
<div><strong>Fase 5 - Explosieve uitval:</strong><br>Volledige ontlading, hard en fel. Communicatie niet meer mogelijk.</div>
</div>
</div>
</details>
</div>
</div>
<div class="form-group">
<label for="incident_time">Tijdstip incident:</label>
<input type="datetime-local" id="incident_time" name="incident_time">
<script>((f)=>f())(()=>{const d=new Date(),i=document.getElementById('incident_time');if(i)i.value=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`})</script>
<small style="color: #6c757d;">Automatisch ingevuld, pas aan indien nodig</small>
</div>
<div class="form-group">
<label for="notes">Wat gebeurde er? (optioneel):</label>
<textarea id="notes" name="notes" placeholder="Beschrijf kort wat er gebeurde..."></textarea>
</div>
<button type="submit" class="btn">📝 Incident opslaan</button>
</form>
</div>
<div class="card">
<h3>📊 Snelle statistieken</h3>
{% if current_user.is_authenticated %}
<p>Bekijk je <a href="{{ url_for('main.statistics') }}">volledige statistieken</a> om trends te zien.</p>
{% else %}
<p>Je gegevens worden <strong>alleen lokaal</strong> opgeslagen. <a href="{{ url_for('main.register') }}">Maak een account</a> om je gegevens veilig te bewaren.</p>
{% endif %}
<div id="quick-stats">
<!-- Quick stats will be loaded here via JavaScript -->
</div>
</div>
{% if not current_user.is_authenticated %}
<div class="card" style="border-left: 4px solid #28a745;">
<h4>🔒 Privacybescherming</h4>
<ul style="margin-left: 1rem;">
<li>✅ Geen persoonlijke gegevens vereist</li>
<li>✅ Data blijft op jouw apparaat</li>
<li>✅ Geen tracking of analytics</li>
<li>✅ Optioneel account voor sync tussen apparaten</li>
</ul>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Load quick stats
document.addEventListener('DOMContentLoaded', function() {
loadQuickStats();
});
function loadQuickStats() {
const statsDiv = document.getElementById('quick-stats');
if ({{ 'true' if current_user.is_authenticated else 'false' }}) {
// For authenticated users - would make API call
statsDiv.innerHTML = '<p>📈 Laad volleidig statistieken pagina voor details</p>';
} else {
// For anonymous users - use localStorage
const incidents = JSON.parse(localStorage.getItem('snauw_incidents') || '[]');
if (incidents.length === 0) {
statsDiv.innerHTML = '<p style="color: #6c757d;">Nog geen incidenten geregistreerd</p>';
} else {
const today = new Date().toDateString();
const todayIncidents = incidents.filter(inc =>
new Date(inc.timestamp).toDateString() === today
);
const avgSeverity = incidents.reduce((sum, inc) => sum + inc.severity, 0) / incidents.length;
statsDiv.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number">${incidents.length}</span>
<span class="stat-label">Totaal incidenten</span>
</div>
<div class="stat-card">
<span class="stat-number">${todayIncidents.length}</span>
<span class="stat-label">Vandaag</span>
</div>
<div class="stat-card">
<span class="stat-number">${avgSeverity.toFixed(1)}</span>
<span class="stat-label">Gem. Snauw-index</span>
</div>
</div>
`;
}
}
}
// Mark body as authenticated or not for JavaScript
document.body.dataset.isAuthenticated = {{ 'true' if current_user.is_authenticated else 'false' }};
</script>
{% endblock %}

41
app/templates/login.html Normal file
View file

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Inloggen - Snauw Counter{% endblock %}
{% block content %}
<div class="card">
<h2>🔐 Inloggen</h2>
<p>Log in om je gegevens veilig op te slaan en te synchroniseren tussen apparaten.</p>
<form action="{{ url_for('main.login') }}" method="POST">
<div class="form-group">
<label for="email">E-mailadres:</label>
<input type="email" id="email" name="email" required
value="{{ request.form.email if request.form.email }}">
</div>
<div class="form-group">
<label for="password">Wachtwoord:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn">🚪 Inloggen</button>
</form>
<p style="margin-top: 1.5rem; text-align: center; color: #6c757d;">
Nog geen account?
<a href="{{ url_for('main.register') }}" style="color: #667eea; text-decoration: none; font-weight: 500;">Registreren</a>
</p>
</div>
<div class="card" style="border-left: 4px solid #28a745;">
<h4>✨ Voordelen van een account</h4>
<ul style="margin-left: 1rem;">
<li>🔄 Synchroniseer tussen meerdere apparaten</li>
<li>☁️ Veilige opslag in de cloud</li>
<li>📊 Uitgebreidere statistieken en trends</li>
<li>💾 Automatische backup van je gegevens</li>
<li>🔒 End-to-end versleuteling</li>
</ul>
</div>
{% endblock %}

185
app/templates/privacy.html Normal file
View file

@ -0,0 +1,185 @@
{% extends "base.html" %}
{% block title %}Privacy Beleid - Snauw Counter{% endblock %}
{% block content %}
<div class="card">
<h1>🔒 Privacy Beleid</h1>
<p><em>Laatst bijgewerkt: 9 januari 2026</em></p>
<h2>Onze Inzet voor Privacy</h2>
<p>Snauw Counter is ontworpen met privacy als uitgangspunt. We begrijpen dat de gegevens die je met ons deelt gevoelig zijn, en we nemen de bescherming ervan zeer serieus.</p>
<h2>Welke Gegevens Verzamelen We?</h2>
<h3>Voor Anonieme Gebruikers</h3>
<ul>
<li><strong>Lokale opslag</strong>: Incidenten worden alleen opgeslagen in je browser (localStorage)</li>
<li><strong>Geen server opslag</strong>: Je gegevens verlaten je apparaat niet</li>
<li><strong>Geen tracking</strong>: We volgen geen gebruikersgedrag</li>
<li><strong>Geen analytics</strong>: Geen Google Analytics of vergelijkbare diensten</li>
</ul>
<h3>Voor Geregistreerde Gebruikers</h3>
<ul>
<li>📧 <strong>E-mailadres</strong>: Voor account identificatie</li>
<li>🔐 <strong>Gehashed wachtwoord</strong>: Veilig opgeslagen (niet leesbaar)</li>
<li>📊 <strong>Incident gegevens</strong>: Datum/tijd, ernstscore, opmerkingen</li>
<li>🕒 <strong>Account metadata</strong>: Aanmaakdatum, laatste login</li>
</ul>
<h2>Hoe Gebruiken We Je Gegevens?</h2>
<ul>
<li>📈 <strong>Statistieken tonen</strong>: Om je persoonlijke trends te berekenen</li>
<li>🔐 <strong>Account beheer</strong>: Voor inloggen en authenticatie</li>
<li>💾 <strong>Data synchronisatie</strong>: Tussen je verschillende apparaten</li>
<li>🛡️ <strong>Beveiliging</strong>: Om je account te beschermen</li>
</ul>
<p><strong>We delen NOOIT je gegevens met derden voor marketing of commerciële doeleinden.</strong></p>
<h2>Gegevensbeveiliging</h2>
<ul>
<li>🔒 <strong>Versleuteling</strong>: Alle gevoelige gegevens worden versleuteld opgeslagen</li>
<li>🛡️ <strong>Beveiligde verbindingen</strong>: HTTPS voor alle communicatie</li>
<li><strong>Beperkte toegang</strong>: Alleen essentiële systemen hebben toegang</li>
<li>🔄 <strong>Reguliere backups</strong>: Met versleuteling voor disaster recovery</li>
</ul>
<h2>Je Rechten (GDPR/AVG)</h2>
<div style="background: #e3f2fd; padding: 1rem; border-radius: 8px; border-left: 4px solid #2196f3; margin: 1rem 0;">
<h4>🇪🇺 Je GDPR Rechten:</h4>
<ul>
<li><strong>Recht op informatie</strong>: Dit privacy beleid</li>
<li><strong>Recht op toegang</strong>: Inzage in je opgeslagen gegevens</li>
<li><strong>Recht op correctie</strong>: Wijzigen van onjuiste gegevens</li>
<li><strong>Recht op verwijdering</strong>: "Recht om vergeten te worden"</li>
<li><strong>Recht op overdraagbaarheid</strong>: Export van je gegevens</li>
<li><strong>Recht op bezwaar</strong>: Tegen verwerking van je gegevens</li>
</ul>
</div>
<h3>Hoe Oefen Je Deze Rechten Uit?</h3>
{% if current_user.is_authenticated %}
<div class="stats-grid" style="margin: 1rem 0;">
<button onclick="window.location.href='{{ url_for('main.export_data') }}'" class="btn btn-secondary">
📥 Gegevens Exporteren
</button>
<button onclick="showDeleteConfirmation()" class="btn btn-danger">
🗑️ Account Verwijderen
</button>
</div>
{% else %}
<div class="stats-grid" style="margin: 1rem 0;">
<button onclick="exportData()" class="btn btn-secondary">
📥 Lokale Gegevens Exporteren
</button>
<button onclick="deleteAllData()" class="btn btn-danger">
🗑️ Lokale Gegevens Wissen
</button>
</div>
{% endif %}
<h2>Cookies & Lokale Opslag</h2>
<h3>Essentiële Cookies (Altijd Actief)</h3>
<ul>
<li>🍪 <strong>Sessie cookie</strong>: Voor ingelogde gebruikers (session management)</li>
<li>📱 <strong>Voorkeuren</strong>: UI instellingen en privacy notice status</li>
</ul>
<h3>Lokale Browser Opslag</h3>
<ul>
<li>💾 <strong>localStorage</strong>: Voor anonieme gebruikers om incidenten bij te houden</li>
<li>⚙️ <strong>App instellingen</strong>: Gebruikersvoorkeuren en UI state</li>
</ul>
<p style="background: #fff3cd; padding: 1rem; border-radius: 8px; border: 1px solid #ffeaa7;">
<strong>⚠️ Let op:</strong> Als je lokale browser gegevens wist (cache/cookies leegmaken),
verlies je als anonieme gebruiker al je opgeslagen incidenten.
<a href="{{ url_for('main.register') }}">Maak een account</a> om dit te voorkomen.
</p>
<h2>Gegevensretentie</h2>
<ul>
<li>📊 <strong>Incident gegevens</strong>: Onbeperkt (of tot je account verwijdert)</li>
<li>🔐 <strong>Account gegevens</strong>: Tot account verwijdering</li>
<li>💾 <strong>Backups</strong>: Maximaal 90 dagen na verwijdering</li>
<li>📱 <strong>Lokale gegevens</strong>: Tot je ze handmatig wist of browser cache leegt</li>
</ul>
<h2>Contact & Vragen</h2>
<p>Heb je vragen over je privacy of dit beleid? </p>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 8px; border: 1px solid #dee2e6;">
<p><strong>📧 Data Protection Officer:</strong></p>
<p>E-mail: privacy@snauwcounter.app<br>
Reactietijd: Binnen 72 uur</p>
<p><strong>🏢 Verwerkingsverantwoordelijke:</strong></p>
<p>Snauw Counter Development Team<br>
Nederland</p>
</div>
</div>
<div class="card" style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); color: white;">
<h3>✅ Privacy First Belofte</h3>
<p>Snauw Counter is gebouwd vanuit de overtuiging dat privacy een fundamenteel recht is. We verzamelen alleen wat noodzakelijk is, beveiligen alles wat we opslaan, en geven jou altijd volledige controle over je gegevens.</p>
<p><strong>Jouw vertrouwen is onze verantwoordelijkheid.</strong></p>
</div>
{% endblock %}
{% block scripts %}
<script>
function showDeleteConfirmation() {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h3>⚠️ Account Verwijderen</h3>
<button onclick="closeModal()" class="close-btn"></button>
</div>
<div class="modal-body">
<p><strong>Dit is permanent en kan niet ongedaan worden gemaakt.</strong></p>
<p>We zullen verwijderen:</p>
<ul>
<li>✅ Je account en login gegevens</li>
<li>✅ Alle geregistreerde incidenten</li>
<li>✅ Alle statistieken en trends</li>
<li>✅ Backups binnen 90 dagen</li>
</ul>
<form action="{{ url_for('main.delete_account') }}" method="POST">
<div class="form-group">
<label for="confirm-delete">Type "DELETE" om te bevestigen:</label>
<input type="text" id="confirm-delete" name="confirm" required
placeholder="DELETE" style="text-transform: uppercase;">
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<button type="submit" class="btn btn-danger">🗑️ Permanent Verwijderen</button>
<button type="button" onclick="closeModal()" class="btn btn-secondary">Annuleren</button>
</div>
</form>
</div>
</div>
`;
document.body.appendChild(modal);
}
function closeModal() {
const modal = document.querySelector('.modal');
if (modal) {
modal.remove();
}
}
// Mark body as authenticated for JavaScript
document.body.dataset.isAuthenticated = {{ 'true' if current_user.is_authenticated else 'false' }};
</script>
{% endblock %}

View file

@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}Registreren - Snauw Counter{% endblock %}
{% block content %}
<div class="card">
<h2>👤 Account aanmaken</h2>
<p>Maak een gratis account om je gegevens veilig op te slaan.</p>
<form action="{{ url_for('main.register') }}" method="POST">
<div class="form-group">
<label for="email">E-mailadres:</label>
<input type="email" id="email" name="email" required
value="{{ request.form.email if request.form.email }}">
<small style="color: #6c757d;">We sturen nooit spam en delen je e-mail niet</small>
</div>
<div class="form-group">
<label for="password">Wachtwoord:</label>
<input type="password" id="password" name="password" required minlength="6">
<small style="color: #6c757d;">Minimaal 6 karakters</small>
</div>
<div class="form-group">
<label for="password_confirm">Bevestig wachtwoord:</label>
<input type="password" id="password_confirm" name="password_confirm" required minlength="6">
</div>
<div class="form-group">
<label style="display: flex; align-items: center; font-weight: normal;">
<input type="checkbox" required style="margin-right: 0.5rem; width: auto;">
Ik ga akkoord met het <a href="{{ url_for('main.privacy') }}" target="_blank" style="color: #667eea;">privacy beleid</a>
</label>
</div>
<button type="submit" class="btn">✨ Account aanmaken</button>
</form>
<p style="margin-top: 1.5rem; text-align: center; color: #6c757d;">
Al een account?
<a href="{{ url_for('main.login') }}" style="color: #667eea; text-decoration: none; font-weight: 500;">Inloggen</a>
</p>
</div>
<div class="card" style="border-left: 4px solid #17a2b8;">
<h4>🔒 Privacy & Beveiliging</h4>
<ul style="margin-left: 1rem;">
<li>✅ Minimale gegevensverzameling</li>
<li>✅ Sterke wachtwoord versleuteling</li>
<li>✅ Geen tracking of advertenties</li>
<li>✅ GDPR/AVG compliant</li>
<li>✅ Data export en verwijdering mogelijk</li>
<li>✅ Optionele anonieme modus beschikbaar</li>
</ul>
</div>
{% if request.endpoint == 'main.register' %}
<div class="card" id="migration-info" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; display: none;">
<h4>💾 Data migratie</h4>
<p>We hebben lokale gegevens gevonden! Na registratie migreren we automatisch je bestaande incidenten naar je nieuwe account.</p>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Check for local data and show migration info
document.addEventListener('DOMContentLoaded', function() {
const localIncidents = localStorage.getItem('snauw_incidents');
if (localIncidents) {
const incidents = JSON.parse(localIncidents);
if (incidents.length > 0) {
const migrationInfo = document.getElementById('migration-info');
if (migrationInfo) {
migrationInfo.style.display = 'block';
migrationInfo.innerHTML = `
<h4>💾 Data migratie</h4>
<p>We hebben ${incidents.length} lokaal opgeslagen incidenten gevonden! Na registratie migreren we deze automatisch naar je nieuwe account.</p>
`;
}
}
}
// Password confirmation validation
const password = document.getElementById('password');
const passwordConfirm = document.getElementById('password_confirm');
passwordConfirm.addEventListener('input', function() {
if (password.value !== passwordConfirm.value) {
passwordConfirm.setCustomValidity('Wachtwoorden komen niet overeen');
} else {
passwordConfirm.setCustomValidity('');
}
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,223 @@
{% extends "base.html" %}
{% block title %}Statistieken - Snauw Counter{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endblock %}
{% block content %}
<h2>📈 Statistieken & Trends</h2>
<!-- Statistiek overzicht -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number">{{ stats.total_incidents }}</span>
<span class="stat-label">Totaal incidenten</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.incidents_today }}</span>
<span class="stat-label">Vandaag</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.avg_severity }}</span>
<span class="stat-label">Gemiddelde Snauw-index</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.most_severe }}</span>
<span class="stat-label">Hoogste Snauw-index</span>
</div>
</div>
<!-- Trend grafiek -->
{% if stats.total_incidents > 0 %}
<div class="card">
<h3>📊 Trend laatste 7 dagen</h3>
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
</div>
<!-- Periode statistieken -->
<div class="card">
<h3>📅 Periode overzicht</h3>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number">{{ stats.incidents_this_week }}</span>
<span class="stat-label">Deze week</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.incidents_this_month }}</span>
<span class="stat-label">Deze maand</span>
</div>
</div>
</div>
<!-- Recent incidents -->
<div class="card">
<h3>🕒 Recente incidenten</h3>
{% if incidents %}
<div style="max-height: 300px; overflow-y: auto;">
{% for incident in incidents[:10] %}
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #e9ecef;">
<div>
<strong>Snauw-index {{ incident.severity }}/5</strong>
{% if incident.notes %}
<br><small style="color: #6c757d;">{{ incident.notes[:50] }}{% if incident.notes|length > 50 %}...{% endif %}</small>
{% endif %}
</div>
<small style="color: #6c757d;">
{{ incident.timestamp.strftime('%d/%m %H:%M') }}
</small>
</div>
{% endfor %}
</div>
{% if incidents|length > 10 %}
<p style="margin-top: 1rem; color: #6c757d; text-align: center;">
En {{ incidents|length - 10 }} andere incidenten...
</p>
{% endif %}
{% else %}
<p style="color: #6c757d;">Nog geen incidenten geregistreerd</p>
{% endif %}
</div>
{% else %}
<!-- Geen data yet -->
<div class="card" style="text-align: center; padding: 3rem;">
<h3>📊 Nog geen statistieken</h3>
<p style="color: #6c757d; margin-bottom: 2rem;">
Registreer je eerste incident om statistieken te zien
</p>
<a href="{{ url_for('main.index') }}" class="btn">📝 Eerste incident toevoegen</a>
</div>
{% endif %}
<!-- GDPR compliance section -->
<div class="card" style="border-left: 4px solid #ffc107;">
<h4>🔒 Privacy & Gegevensbeheer</h4>
<p>Je hebt altijd controle over je gegevens:</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem;">
<button onclick="exportData()" class="btn btn-secondary">📥 Data exporteren</button>
{% if current_user.is_authenticated %}
<form action="{{ url_for('main.delete_account') }}" method="POST" style="display: inline;"
onsubmit="return confirm('Type DELETE om te bevestigen') && prompt('Type DELETE:') === 'DELETE'">
<input type="hidden" name="confirm" value="DELETE">
<button type="submit" class="btn btn-danger">🗑️ Account verwijderen</button>
</form>
{% else %}
<button onclick="deleteAllData()" class="btn btn-danger">🗑️ Alle data wissen</button>
{% endif %}
</div>
<p style="margin-top: 1rem; font-size: 0.9rem; color: #6c757d;">
<a href="{{ url_for('main.privacy') }}" style="color: inherit;">Privacy beleid</a> |
Conform AVG/GDPR
</p>
</div>
{% endblock %}
{% block scripts %}
<script>
// Mark body as authenticated for JavaScript
document.body.dataset.isAuthenticated = {{ 'true' if current_user.is_authenticated else 'false' }};
{% if stats.total_incidents > 0 %}
// Chart.js implementation
const trendData = {{ stats.trend_data | tojson }};
const ctx = document.getElementById('trendChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: trendData.map(d => d.date),
datasets: [{
label: 'Aantal incidenten',
data: trendData.map(d => d.count),
backgroundColor: 'rgba(102, 126, 234, 0.6)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 2,
yAxisID: 'y'
}, {
label: 'Gemiddelde Snauw-index',
data: trendData.map(d => d.avg_severity),
type: 'line',
borderColor: 'rgba(220, 53, 69, 1)',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
borderWidth: 3,
fill: false,
yAxisID: 'y1'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Incident Trends (Laatste 7 dagen)'
},
legend: {
display: true
}
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
beginAtZero: true,
title: {
display: true,
text: 'Aantal incidenten'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
min: 0,
max: 5,
title: {
display: true,
text: 'Gemiddelde Snauw-index'
},
grid: {
drawOnChartArea: false,
}
}
},
interaction: {
mode: 'index',
intersect: false,
}
}
});
{% endif %}
// Load anonymous user data if not authenticated
if (!{{ 'true' if current_user.is_authenticated else 'false' }}) {
// This would enhance the statistics with localStorage data
enhanceWithLocalData();
}
function enhanceWithLocalData() {
// For anonymous users, we could enhance server statistics
// with localStorage data that might not be synced yet
const localIncidents = JSON.parse(localStorage.getItem('snauw_incidents') || '[]');
if (localIncidents.length > 0) {
// Add notification about local data
const notice = document.createElement('div');
notice.className = 'alert alert-info';
notice.innerHTML = `
Je hebt ${localIncidents.length} lokaal opgeslagen incidenten.
<a href="{{ url_for('main.register') }}">Maak een account</a> om deze veilig op te slaan.
`;
document.querySelector('main').insertBefore(notice, document.querySelector('main').firstChild);
}
}
</script>
{% endblock %}