Compare commits

...

6 Commits

6 changed files with 329 additions and 339 deletions

View File

@@ -7,11 +7,13 @@ import os, json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from io import BytesIO from io import BytesIO
from xhtml2pdf import pisa from xhtml2pdf import pisa
from sqlalchemy import text
app = Flask(__name__) app = Flask(__name__)
# --- Konfiguration --- # --- Konfiguration ---
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:////app/data/raceplanner.db') db_path = os.path.join('/app/data', 'raceplanner.db')
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = 'renn-strategie-2026-final-v3' app.config['SECRET_KEY'] = 'renn-strategie-2026-final-v3'
app.config['REMEMBER_COOKIE_DURATION'] = timedelta(days=31) app.config['REMEMBER_COOKIE_DURATION'] = timedelta(days=31)
@@ -30,244 +32,169 @@ if not os.path.exists(SCENARIO_DIR):
# --- Models --- # --- Models ---
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False) username = db.Column(db.String(80), unique=True, nullable=False)
password = db.Column(db.String(200), nullable=False) password = db.Column(db.String(200), nullable=False)
theme = db.Column(db.String(20), default='light')
class Driver(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
car_number = db.Column(db.Integer, nullable=False)
cons_per_lap = db.Column(db.Float, default=16.0)
avg_lap_time = db.Column(db.Float, default=540.0)
tire_life_laps = db.Column(db.Integer, default=50)
order_index = db.Column(db.Integer, default=0)
class Stint(db.Model):
id = db.Column(db.Integer, primary_key=True)
car_number = db.Column(db.Integer, nullable=False)
driver_id = db.Column(db.Integer, db.ForeignKey('driver.id'))
order_index = db.Column(db.Integer, default=0)
driver = db.relationship('Driver', backref='stints')
class RaceConfig(db.Model): class RaceConfig(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tank_capacity = db.Column(db.Float, default=100.0) start_time = db.Column(db.DateTime, default=datetime.now)
race_duration_hours = db.Column(db.Integer, default=24) total_duration_h = db.Column(db.Integer, default=24)
start_time = db.Column(db.DateTime, default=datetime(2026, 1, 31, 12, 0))
interruption_mins = db.Column(db.Integer, default=0) class Driver(db.Model):
always_change_tires = db.Column(db.Boolean, default=False) id = db.Column(db.Integer, primary_key=True)
min_pit_stop_sec = db.Column(db.Integer, default=180) name = db.Column(db.String(100))
car_number = db.Column(db.Integer)
avg_lap_time = db.Column(db.Float)
cons_per_lap = db.Column(db.Float)
class Stint(db.Model):
id = db.Column(db.Integer, primary_key=True)
car_number = db.Column(db.Integer)
driver_id = db.Column(db.Integer, db.ForeignKey('driver.id'))
order = db.Column(db.Integer)
change_tires = db.Column(db.Boolean, default=False)
driver = db.relationship('Driver', backref='stints')
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
return db.session.get(User, int(user_id)) return db.session.get(User, int(user_id))
def save_undo_state(car_num): # --- Hilfsfunktionen ---
stints = Stint.query.filter_by(car_number=car_num).order_by(Stint.order_index).all()
session[f'undo_{car_num}'] = [{'driver_id': s.driver_id, 'order_index': s.order_index} for s in stints]
def get_calculated_schedule(car_num): def get_calculated_schedule(car_num):
config = RaceConfig.query.first() config = RaceConfig.query.first()
stints = Stint.query.filter_by(car_number=car_num).order_by(Stint.order_index).all() if not config: return []
stints = Stint.query.filter_by(car_number=car_num).order_by(Stint.order).all()
schedule = [] schedule = []
current_time = (config.start_time or datetime(2026, 1, 31, 12, 0)) + timedelta(minutes=config.interruption_mins) current_time = config.start_time
race_end = config.start_time + timedelta(hours=config.race_duration_hours) fuel_capacity = 120.0
current_tire_laps = 0
for i, s in enumerate(stints): for i, stint in enumerate(stints):
if current_time >= race_end: break driver = stint.driver
driver = s.driver
if not driver: continue if not driver: continue
laps_possible = int(fuel_capacity / (driver.cons_per_lap or 1.0))
laps_possible = int(config.tank_capacity / driver.cons_per_lap) duration_sec = laps_possible * (driver.avg_lap_time or 120.0)
stint_dur = laps_possible * driver.avg_lap_time start_str = current_time.strftime('%H:%M')
end_time = current_time + timedelta(seconds=stint_dur) end_time = current_time + timedelta(seconds=duration_sec)
is_finish = False
laps_to_do = laps_possible
if end_time >= race_end:
is_finish = True
rem_sec = (race_end - current_time).total_seconds()
laps_to_do = int(rem_sec / driver.avg_lap_time)
end_time = race_end
change_tires = config.always_change_tires or (current_tire_laps + laps_to_do) > driver.tire_life_laps
fuel = round(laps_to_do * driver.cons_per_lap, 1)
schedule.append({ schedule.append({
'number': i + 1, 'stint_id': s.id, 'driver_id': driver.id, 'driver_name': driver.name, 'id': stint.id,
'start': current_time.strftime('%H:%M'), 'end': end_time.strftime('%H:%M'), 'number': i + 1,
'date': current_time.strftime('%d.%m.'), 'laps': laps_to_do, 'fuel': fuel, 'start': start_str,
'change_tires': change_tires, 'is_finish': is_finish 'end': end_time.strftime('%H:%M'),
'date': current_time.strftime('%d.%m. '),
'driver_name': driver.name,
'driver_id': driver.id,
'laps': laps_possible,
'fuel': round(fuel_capacity, 1),
'change_tires': stint.change_tires,
'is_finish': end_time >= (config.start_time + timedelta(hours=config.total_duration_h))
}) })
if is_finish: break current_time = end_time
current_tire_laps = laps_to_do if change_tires else current_tire_laps + laps_to_do
current_time = end_time + timedelta(seconds=config.min_pit_stop_sec)
return schedule return schedule
# --- Szenario Routen (Speichern/Laden) ---
@app.route('/save_scenario', methods=['POST'])
@login_required
def save_scenario():
name = request.json.get('name', 'unnamed_scenario')
filename = f"{name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
data = {
'config': {
'start_time': RaceConfig.query.first().start_time.isoformat(),
'total_duration_h': RaceConfig.query.first().total_duration_h
},
'stints': []
}
for s in Stint.query.all():
data['stints'].append({
'car_number': s.car_number,
'driver_id': s.driver_id,
'order': s.order,
'change_tires': s.change_tires
})
with open(os.path.join(SCENARIO_DIR, filename), 'w') as f:
json.dump(data, f)
return jsonify({'status': 'ok', 'filename': filename})
@app.route('/list_scenarios')
@login_required
def list_scenarios():
files = [f for f in os.listdir(SCENARIO_DIR) if f.endswith('.json')]
return jsonify(sorted(files, reverse=True))
@app.route('/load_scenario', methods=['POST'])
@login_required
def load_scenario():
filename = request.json.get('filename')
path = os.path.join(SCENARIO_DIR, filename)
if not os.path.exists(path):
return jsonify({'status': 'error', 'message': 'Datei nicht gefunden'})
with open(path, 'r') as f:
data = json.load(f)
Stint.query.delete()
for s_data in data['stints']:
db.session.add(Stint(**s_data))
db.session.commit()
return jsonify({'status': 'ok'})
# --- Standard Routen ---
@app.route('/') @app.route('/')
@login_required @login_required
def index(): def index():
drivers = Driver.query.all()
config = RaceConfig.query.first() config = RaceConfig.query.first()
sch1, sch2 = get_calculated_schedule(1), get_calculated_schedule(2) car1_sch = get_calculated_schedule(1)
stint_counts = {d.id: 0 for d in Driver.query.all()} car2_sch = get_calculated_schedule(2)
for s in sch1 + sch2: return render_template('index.html', car1=car1_sch, car2=car2_sch, drivers=drivers, config=config)
stint_counts[s['driver_id']] = stint_counts.get(s['driver_id'], 0) + 1
c1_drivers = Driver.query.filter_by(car_number=1).order_by(Driver.order_index).all() @app.route('/update_theme', methods=['POST'])
c2_drivers = Driver.query.filter_by(car_number=2).order_by(Driver.order_index).all()
scenarios = [f for f in os.listdir(SCENARIO_DIR) if f.endswith('.json')]
return render_template('index.html', config=config, schedule1=sch1, schedule2=sch2,
car1_drivers=c1_drivers, car2_drivers=c2_drivers,
stint_counts=stint_counts, scenarios=scenarios)
@app.route('/update_config', methods=['POST'])
@login_required @login_required
def update_config(): def update_theme():
config = RaceConfig.query.first() data = request.get_json()
config.tank_capacity = float(request.form.get('tank_capacity')) new_theme = data.get('theme')
config.race_duration_hours = int(request.form.get('race_duration_hours')) if new_theme in ['light', 'dark']:
config.min_pit_stop_sec = int(request.form.get('min_pit_stop_sec')) current_user.theme = new_theme
config.always_change_tires = 'always_change_tires' in request.form
dt_str = request.form.get('start_datetime')
if dt_str: config.start_time = datetime.strptime(dt_str, '%Y-%m-%dT%H:%M')
db.session.commit() db.session.commit()
return redirect(url_for('index')) return jsonify({'status': 'success'})
return jsonify({'status': 'error'}), 400
@app.route('/add_driver', methods=['POST'])
@login_required
def add_driver():
car_num = int(request.form.get('car_number'))
new_d = Driver(name=request.form.get('name'), car_number=car_num,
cons_per_lap=float(request.form.get('cons') or 16.0),
avg_lap_time=float(request.form.get('lap_time') or 540.0))
db.session.add(new_d); db.session.commit()
return redirect(url_for('index'))
@app.route('/update_driver/<int:id>', methods=['POST'])
@login_required
def update_driver(id):
d = db.session.get(Driver, id)
if d:
d.name = request.form.get('name')
d.cons_per_lap = float(request.form.get('cons'))
d.avg_lap_time = float(request.form.get('lap_time'))
db.session.commit()
return redirect(url_for('index'))
@app.route('/delete_driver/<int:id>')
@login_required
def delete_driver(id):
d = db.session.get(Driver, id)
if d:
Stint.query.filter_by(driver_id=id).delete()
db.session.delete(d); db.session.commit()
return redirect(url_for('index'))
@app.route('/update_stint_driver', methods=['POST']) @app.route('/update_stint_driver', methods=['POST'])
@login_required @login_required
def update_stint_driver(): def update_stint_driver():
data = request.json data = request.json
stint = db.session.get(Stint, data.get('stint_id')) s = db.session.get(Stint, data['stint_id'])
if stint: if s:
save_undo_state(stint.car_number) s.driver_id = data['driver_id']
stint.driver_id = data.get('driver_id')
db.session.commit() db.session.commit()
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
@app.route('/reorder_stints', methods=['POST']) @app.route('/reorder_stints', methods=['POST'])
@login_required @login_required
def reorder_stints(): def reorder_stints():
order = request.json.get('order', []) order = request.json['order']
if order:
first = db.session.get(Stint, order[0])
if first: save_undo_state(first.car_number)
for idx, sid in enumerate(order): for idx, sid in enumerate(order):
s = db.session.get(Stint, sid) s = db.session.get(Stint, int(sid))
if s: s.order_index = idx if s: s.order = idx
db.session.commit() db.session.commit()
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
@app.route('/undo/<int:car_num>') @app.route('/export_pdf/<int:car_num>')
@login_required @login_required
def undo(car_num): def export_pdf(car_num):
state = session.get(f'undo_{car_num}')
if state:
Stint.query.filter_by(car_number=car_num).delete()
for s_data in state:
db.session.add(Stint(car_number=car_num, driver_id=s_data['driver_id'], order_index=s_data['order_index']))
db.session.commit()
session.pop(f'undo_{car_num}')
return redirect(url_for('index'))
@app.route('/generate_schedule/<int:car_num>')
@login_required
def generate_schedule(car_num):
drivers = Driver.query.filter_by(car_number=car_num).order_by(Driver.order_index).all()
if not drivers: return redirect(url_for('index'))
save_undo_state(car_num)
Stint.query.filter_by(car_number=car_num).delete()
for i in range(40):
db.session.add(Stint(car_number=car_num, driver_id=drivers[i % len(drivers)].id, order_index=i))
db.session.commit()
return redirect(url_for('index'))
@app.route('/save_scenario', methods=['POST'])
@login_required
def save_scenario():
name = request.form.get('scenario_name')
config = RaceConfig.query.first() config = RaceConfig.query.first()
data = { sch = get_calculated_schedule(car_num)
'config': {'tank_capacity': config.tank_capacity, 'race_duration_hours': config.race_duration_hours, 'start_time': config.start_time.isoformat(), 'min_pit_stop_sec': config.min_pit_stop_sec, 'always_change_tires': config.always_change_tires}, html = render_template('pdf_template.html', schedule=sch, config=config, title=f"Strategie Fahrzeug #{car_num}")
'drivers': [{'id': d.id, 'name': d.name, 'car_number': d.car_number, 'cons_per_lap': d.cons_per_lap, 'avg_lap_time': d.avg_lap_time} for d in Driver.query.all()],
'stints': [{'car_number': s.car_number, 'driver_id': s.driver_id, 'order_index': s.order_index} for s in Stint.query.all()]
}
with open(os.path.join(SCENARIO_DIR, f"{name}.json"), 'w') as f: json.dump(data, f)
return redirect(url_for('index'))
@app.route('/load_scenario', methods=['POST'])
@login_required
def load_scenario():
path = os.path.join(SCENARIO_DIR, request.form.get('scenario_file'))
with open(path, 'r') as f: data = json.load(f)
Stint.query.delete(); Driver.query.delete(); RaceConfig.query.delete()
c = data['config']
db.session.add(RaceConfig(tank_capacity=c['tank_capacity'], race_duration_hours=c['race_duration_hours'], start_time=datetime.fromisoformat(c['start_time']), min_pit_stop_sec=c.get('min_pit_stop_sec', 180), always_change_tires=c['always_change_tires']))
id_map = {}
for d in data['drivers']:
new_d = Driver(name=d['name'], car_number=d['car_number'], cons_per_lap=d['cons_per_lap'], avg_lap_time=d['avg_lap_time'])
db.session.add(new_d); db.session.flush(); id_map[d['id']] = new_d.id
for s in data['stints']:
db.session.add(Stint(car_number=s['car_number'], driver_id=id_map.get(s['driver_id']), order_index=s['order_index']))
db.session.commit()
return redirect(url_for('index'))
@app.route('/export/car/<int:car_num>')
@login_required
def export_car_pdf(car_num):
config = RaceConfig.query.first()
html = render_template('pdf_template.html', schedule=get_calculated_schedule(car_num), config=config, title=f"Auto #{car_num}")
res = BytesIO() res = BytesIO()
pisa.CreatePDF(BytesIO(html.encode("utf-8")), dest=res) pisa.CreatePDF(BytesIO(html.encode("utf-8")), dest=res)
response = make_response(res.getvalue()) response = make_response(res.getvalue())
response.headers['Content-Type'] = 'application/pdf' response.headers['Content-Type'] = 'application/pdf'
return response return response
@app.route('/export/driver/<int:driver_id>')
@login_required
def export_driver_pdf(driver_id):
driver = db.session.get(Driver, driver_id)
full_sch = get_calculated_schedule(driver.car_number)
stints = [s for s in full_sch if s['driver_id'] == driver_id]
config = RaceConfig.query.first()
html = render_template('pdf_template.html', schedule=stints, config=config, title=f"Fahrer-Plan: {driver.name}")
res = BytesIO()
pisa.CreatePDF(BytesIO(html.encode("utf-8")), dest=res)
response = make_response(res.getvalue())
response.headers['Content-Type'] = 'application/pdf'
return response
# HIER IST DIE FEHLENDE ROUTE
@app.route('/driver/<int:id>') @app.route('/driver/<int:id>')
@login_required @login_required
def driver_detail(id): def driver_detail(id):
@@ -291,11 +218,43 @@ def logout():
logout_user() logout_user()
return redirect(url_for('login')) return redirect(url_for('login'))
if __name__ == '__main__': def init_db():
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
if not RaceConfig.query.first(): db.session.add(RaceConfig()); db.session.commit() try:
if not User.query.filter_by(username='mscaltenbach').first(): db.session.execute(text("ALTER TABLE user ADD COLUMN theme VARCHAR(20) DEFAULT 'light'"))
db.session.add(User(username='mscaltenbach', password=generate_password_hash('SendIt123!', method='pbkdf2:sha256')))
db.session.commit() db.session.commit()
app.run(host='0.0.0.0', port=5000) except Exception:
db.session.rollback()
target_username = 'mscaltenbach'
target_password = 'SendIt123!'
admin_user = User.query.filter_by(username=target_username).first()
if not admin_user:
hashed_pw = generate_password_hash(target_password)
db.session.add(User(username=target_username, password=hashed_pw, theme='light'))
db.session.commit()
if not RaceConfig.query.first():
db.session.add(RaceConfig(start_time=datetime.now(), total_duration_h=24))
db.session.commit()
if not Driver.query.first():
drivers = [
Driver(name="Caltenbach", car_number=1, avg_lap_time=128.5, cons_per_lap=3.8),
Driver(name="Mueller", car_number=1, avg_lap_time=130.2, cons_per_lap=3.6),
Driver(name="Schmidt", car_number=2, avg_lap_time=129.1, cons_per_lap=3.7),
Driver(name="Weber", car_number=2, avg_lap_time=131.5, cons_per_lap=3.5)
]
db.session.add_all(drivers)
db.session.commit()
d1, d2 = Driver.query.filter_by(car_number=1).first(), Driver.query.filter_by(car_number=2).first()
if d1 and d2:
for i in range(12):
db.session.add(Stint(car_number=1, driver_id=d1.id, order=i))
db.session.add(Stint(car_number=2, driver_id=d2.id, order=i))
db.session.commit()
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -4,42 +4,80 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{{ driver.name }} - Details</title> <title>{{ driver.name }} - Details</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
:root {
--bg-body: {{ '#0a0e17' if current_user.theme == 'dark' else '#f8fafc' }};
--bg-card: {{ '#0f172a' if current_user.theme == 'dark' else '#ffffff' }};
--text-main: {{ '#e2e8f0' if current_user.theme == 'dark' else '#1e293b' }};
--border-color: {{ 'rgba(255,255,255,0.05)' if current_user.theme == 'dark' else '#e2e8f0' }};
}
body {
background-color: var(--bg-body);
color: var(--text-main);
font-family: 'Inter', sans-serif;
transition: background 0.3s ease;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
</style>
</head> </head>
<body class="bg-[#0a0e17] text-white p-8"> <body class="p-4 md:p-8">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-8"> <!-- Header -->
<h1 class="text-4xl font-black uppercase italic">{{ driver.name }} <span class="text-blue-500">#{{ driver.car_number }}</span></h1> <div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-12 gap-6">
<a href="/" class="bg-slate-800 px-4 py-2 rounded text-xs uppercase font-bold">Zurück</a>
</div>
<div class="grid grid-cols-3 gap-4 mb-8">
<div class="bg-slate-900/50 p-4 rounded-xl border border-white/5">
<div class="text-[10px] text-slate-500 uppercase font-bold">Verbrauch/Runde</div>
<div class="text-2xl font-black text-green-500">{{ driver.cons_per_lap }}L</div>
</div>
<div class="bg-slate-900/50 p-4 rounded-xl border border-white/5">
<div class="text-[10px] text-slate-500 uppercase font-bold">Ø Rundenzeit</div>
<div class="text-2xl font-black text-blue-500">{{ driver.avg_lap_time }}s</div>
</div>
<div class="bg-slate-900/50 p-4 rounded-xl border border-white/5">
<div class="text-[10px] text-slate-500 uppercase font-bold">Stints Gesamt</div>
<div class="text-2xl font-black text-orange-500">{{ stints|length }}</div>
</div>
</div>
<h2 class="text-xl font-bold mb-4 uppercase text-slate-400">Geplante Einsätze</h2>
<div class="space-y-2">
{% for s in stints %}
<div class="bg-slate-900/80 p-4 rounded-lg flex justify-between items-center border-l-4 border-blue-600">
<div> <div>
<div class="text-[10px] text-slate-500 font-mono">{{ s.date }}</div> <h1 class="text-5xl font-black italic uppercase tracking-tighter">
<div class="text-lg font-black">{{ s.start }} - {{ s.end }}</div> {{ driver.name }} <span class="text-blue-600">#{{ driver.car_number }}</span>
</h1>
<p class="text-slate-500 font-bold uppercase tracking-widest text-xs mt-2">Driver Profile & Schedule</p>
</div>
<a href="/" class="bg-blue-600 text-white px-8 py-4 rounded-2xl text-xs uppercase font-black hover:bg-blue-700 transition-all shadow-lg shadow-blue-600/20">Zurück zum Planner</a>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-12">
<div class="card p-6 rounded-3xl">
<div class="text-[10px] text-slate-500 uppercase font-bold mb-1 tracking-widest">Verbrauch / Runde</div>
<div class="text-4xl font-black text-green-500">{{ driver.cons_per_lap }}<span class="text-lg ml-1">L</span></div>
</div>
<div class="card p-6 rounded-3xl">
<div class="text-[10px] text-slate-500 uppercase font-bold mb-1 tracking-widest">Ø Rundenzeit</div>
<div class="text-4xl font-black text-blue-600">{{ driver.avg_lap_time }}<span class="text-lg ml-1">s</span></div>
</div>
<div class="card p-6 rounded-3xl">
<div class="text-[10px] text-slate-500 uppercase font-bold mb-1 tracking-widest">Anzahl Stints</div>
<div class="text-4xl font-black text-orange-500">{{ stints|length }}</div>
</div>
</div>
<!-- Schedule -->
<h2 class="text-2xl font-black mb-6 uppercase tracking-tight italic flex items-center gap-3">
<span class="w-8 h-1 bg-blue-600 rounded-full"></span>
Einsatzplanung
</h2>
<div class="space-y-4">
{% for s in stints %}
<div class="card p-6 rounded-3xl flex justify-between items-center border-l-8 border-blue-600 transition-transform hover:scale-[1.01]">
<div>
<div class="text-[10px] text-slate-500 font-bold uppercase tracking-widest mb-1">{{ s.date }}</div>
<div class="text-3xl font-black tracking-tight">{{ s.start }} — {{ s.end }}</div>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="text-sm font-bold">{{ s.laps }} Runden</div> <div class="text-sm font-black uppercase text-blue-600 mb-1">Stint #{{ s.number }}</div>
<div class="text-xs text-green-500">{{ s.fuel }}L Benzin</div> <div class="text-xs font-bold text-slate-500">{{ s.laps }} Runden · {{ s.fuel }}L Start</div>
</div> </div>
</div> </div>
{% else %}
<div class="text-center py-20 card rounded-[2rem] opacity-50">
<p class="font-black uppercase tracking-widest text-slate-400">Keine Stints geplant</p>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

View File

@@ -8,117 +8,75 @@
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
body { font-family: 'Inter', sans-serif; background-color: #0a0e17; color: #e2e8f0; } :root {
.glass { background: rgba(15, 23, 42, 0.8); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.05); } --bg-body: {{ '#0a0e17' if current_user.theme == 'dark' else '#f8fafc' }};
.stint-card { transition: all 0.2s ease; border-left: 4px solid transparent; } --bg-card: {{ '#0f172a' if current_user.theme == 'dark' else '#ffffff' }};
.stint-card:hover { transform: translateX(4px); background: rgba(30, 41, 59, 0.7); } --text-main: {{ '#e2e8f0' if current_user.theme == 'dark' else '#1e293b' }};
--text-muted: {{ '#64748b' if current_user.theme == 'dark' else '#94a3b8' }};
--border-color: {{ 'rgba(255,255,255,0.05)' if current_user.theme == 'dark' else '#e2e8f0' }};
}
body { font-family: 'Inter', sans-serif; background-color: var(--bg-body); color: var(--text-main); transition: background 0.3s ease; }
.glass { background: var(--bg-card); border: 1px solid var(--border-color); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); }
.stint-card { transition: all 0.2s ease; border-left: 4px solid transparent; background: {{ 'rgba(30, 41, 59, 0.5)' if current_user.theme == 'dark' else '#f1f5f9' }}; }
.stint-card:hover { transform: translateX(4px); background: {{ 'rgba(30, 41, 59, 0.8)' if current_user.theme == 'dark' else '#e2e8f0' }}; }
.handle { cursor: grab; } .handle { cursor: grab; }
input, select { background: #0f172a; border: 1px solid #1e293b; color: white; padding: 0.5rem; border-radius: 0.375rem; } select { background: {{ '#1e293b' if current_user.theme == 'dark' else '#ffffff' }}; border: 1px solid var(--border-color); color: var(--text-main); outline: none; }
</style> </style>
</head> </head>
<body class="p-4 md:p-8"> <body class="p-4 md:p-8">
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
<div class="flex flex-col md:flex-row justify-between items-center mb-8 gap-4"> <!-- Header -->
<h1 class="text-3xl font-black italic tracking-tighter text-blue-500 uppercase">Raceplanner <span class="text-white">2026</span></h1> <header class="flex flex-col md:flex-row justify-between items-center mb-10 gap-4">
<div class="flex gap-4 items-center glass p-4 rounded-xl"> <div>
<form action="/save_scenario" method="POST" class="flex gap-2"> <h1 class="text-4xl font-black italic tracking-tighter text-blue-600">RACEPLANNER <span class="text-slate-400 not-italic font-light">2026</span></h1>
<input type="text" name="scenario_name" placeholder="Scenario Name" class="text-xs w-32" required> <p class="text-xs font-bold uppercase tracking-widest text-slate-500">Endurance Strategy Engine</p>
<button class="bg-green-600 hover:bg-green-500 text-[10px] font-bold px-3 py-2 rounded uppercase">Save</button>
</form>
<form action="/load_scenario" method="POST" class="flex gap-2 border-l border-slate-700 pl-4">
<select name="scenario_file" class="text-xs w-32">
{% for s in scenarios %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
</select>
<button class="bg-blue-600 hover:bg-blue-500 text-[10px] font-bold px-3 py-2 rounded uppercase">Load</button>
</form>
<a href="/logout" class="text-xs text-slate-500 hover:text-white ml-2">Logout</a>
</div>
</div> </div>
<form action="/update_config" method="POST" class="glass p-6 rounded-2xl mb-12 grid grid-cols-2 md:grid-cols-6 gap-6 items-end"> <div class="flex flex-wrap items-center gap-4 justify-center">
<div class="col-span-2"> <!-- Scenario Actions -->
<label class="block text-[10px] uppercase font-bold text-slate-500 mb-2">Race Start</label> <button onclick="saveScenarioPrompt()" class="bg-emerald-600 hover:bg-emerald-700 text-white text-[10px] font-black px-4 py-2 rounded-full uppercase tracking-widest">Sichern</button>
<input type="datetime-local" name="start_datetime" value="{{ config.start_time.strftime('%Y-%m-%dT%H:%M') }}" class="w-full font-mono text-sm"> <button onclick="loadScenarioList()" class="bg-slate-600 hover:bg-slate-700 text-white text-[10px] font-black px-4 py-2 rounded-full uppercase tracking-widest">Laden</button>
</div>
<div><label class="block text-[10px] uppercase font-bold text-slate-500 mb-2">Dur(h)</label><input type="number" name="race_duration_hours" value="{{ config.race_duration_hours }}" class="w-full text-center"></div>
<div><label class="block text-[10px] uppercase font-bold text-slate-500 mb-2">Tank</label><input type="number" step="0.1" name="tank_capacity" value="{{ config.tank_capacity }}" class="w-full text-center"></div>
<div><label class="block text-[10px] uppercase font-bold text-slate-500 mb-2">Box(s)</label><input type="number" name="min_pit_stop_sec" value="{{ config.min_pit_stop_sec }}" class="w-full text-center"></div>
<div class="flex items-center gap-2 pb-3">
<input type="checkbox" name="always_change_tires" id="tires" {% if config.always_change_tires %}checked{% endif %}>
<label for="tires" class="text-[10px] uppercase font-bold text-slate-400">Force Tires</label>
<button type="submit" class="ml-4 bg-blue-600 hover:bg-blue-500 text-white font-black text-xs px-6 py-2 rounded uppercase shadow-lg shadow-blue-900/20">Apply</button>
</div>
</form>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12"> <div class="flex bg-slate-200 dark:bg-slate-800 p-1 rounded-lg">
{% for car_num, drivers, schedule in [(1, car1_drivers, schedule1), (2, car2_drivers, schedule2)] %} <button onclick="setTheme('light')" class="px-3 py-1 rounded {{ 'bg-white shadow-sm' if current_user.theme == 'light' else '' }} text-xs font-bold uppercase">Light</button>
<div class="space-y-6"> <button onclick="setTheme('dark')" class="px-3 py-1 rounded {{ 'bg-blue-600 text-white shadow-sm' if current_user.theme == 'dark' else '' }} text-xs font-bold uppercase">Dark</button>
<div class="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 class="text-2xl font-black italic uppercase"><span class="text-blue-500">#{{ car_num }}</span> Vehicle Dashboard</h2>
<div class="flex gap-2">
<a href="/export/car/{{ car_num }}" target="_blank" class="bg-slate-800 hover:bg-slate-700 text-[10px] font-bold px-3 py-1.5 rounded uppercase">PDF Plan</a>
<a href="/generate_schedule/{{ car_num }}" class="bg-red-950 text-red-400 border border-red-900/50 text-[10px] font-bold px-3 py-1.5 rounded uppercase">Auto-Generate</a>
</div> </div>
<a href="/logout" class="text-xs font-bold uppercase px-4 py-2 border border-red-500/50 text-red-500 rounded hover:bg-red-500 transition-all">Logout</a>
</div>
</header>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{% for car_num in [1, 2] %}
<div class="glass rounded-3xl overflow-hidden p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-black italic">CAR <span class="text-blue-600">#{{ car_num }}</span></h2>
<a href="/export_pdf/{{ car_num }}" class="bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black px-4 py-2 rounded-full uppercase tracking-widest">PDF Export</a>
</div> </div>
<div class="space-y-3"> <div id="stints-car-{{ car_num }}" class="space-y-3">
{% for d in drivers %} {% set schedule = car1 if car_num == 1 else car2 %}
<form action="/update_driver/{{ d.id }}" method="POST" class="glass p-4 rounded-xl flex items-center gap-4 stint-card">
<div class="w-8 h-8 rounded-full bg-orange-500/20 text-orange-500 flex items-center justify-center font-black text-xs">{{ stint_counts.get(d.id, 0) }}</div>
<div class="flex-1"><input type="text" name="name" value="{{ d.name }}" class="bg-transparent border-none p-0 font-bold text-blue-400 w-full"></div>
<div class="text-right"><label class="block text-[8px] uppercase text-slate-500">L/R</label><input type="number" step="0.1" name="cons" value="{{ d.cons_per_lap }}" class="w-16 bg-slate-900/50 text-[10px] text-center"></div>
<div class="text-right"><label class="block text-[8px] uppercase text-slate-500">Sec</label><input type="number" step="0.1" name="lap_time" value="{{ d.avg_lap_time }}" class="w-16 bg-slate-900/50 text-[10px] text-center"></div>
<div class="flex gap-2 ml-auto">
<button type="submit" class="bg-blue-600/20 text-blue-400 text-[10px] px-2 py-1 rounded font-black border border-blue-600/30">OK</button>
<a href="/export/driver/{{ d.id }}" target="_blank" class="bg-gray-800 px-2 py-1 rounded text-xs hover:bg-green-600">📄</a>
<a href="/driver/{{ d.id }}" class="bg-gray-800 px-2 py-1 rounded text-xs hover:bg-blue-600">👁️</a>
<a href="/delete_driver/{{ d.id }}" class="text-slate-600 hover:text-red-500"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M6 18L18 6M6 6l12 12"/></svg></a>
</div>
</form>
{% endfor %}
<form action="/add_driver" method="POST" class="flex gap-2 mt-4">
<input type="hidden" name="car_number" value="{{ car_num }}">
<input type="text" name="name" placeholder="Driver Name" class="flex-1 text-sm h-10" required>
<input type="number" step="0.1" name="cons" placeholder="L/R" class="w-20 text-sm h-10">
<input type="number" step="0.1" name="lap_time" placeholder="Sec" class="w-20 text-sm h-10">
<button class="bg-blue-600 text-white font-black text-[10px] px-6 py-2 rounded uppercase">Add</button>
</form>
</div>
<div class="relative py-8">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-slate-800"></div>
</div>
<div class="relative flex justify-center">
<span class="bg-[#0a0e17] px-4 text-xs font-black text-slate-500 uppercase tracking-widest">Race Schedule #{{ car_num }}</span>
</div>
</div>
<div id="stints-car-{{ car_num }}" class="space-y-2">
{% for s in schedule %} {% for s in schedule %}
<div class="stint-item glass rounded-xl p-4 stint-card grid grid-cols-12 gap-2 items-center" data-id="{{ s.stint_id }}"> <div class="stint-card p-4 rounded-xl flex items-center gap-4 group {{ 'border-blue-600' if s.is_finish else 'border-slate-400/20' }}" data-id="{{ s.id }}">
<div class="col-span-1 text-sm font-black text-blue-400">#{{ s.number }}</div> <div class="handle text-slate-500 opacity-20 group-hover:opacity-100">
<div class="col-span-1 handle text-slate-700 hover:text-slate-400"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M7 10l5-5 5 5M7 14l5 5 5-5"/></svg>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M7 7h2v2H7V7zm0 4h2v2H7v-2zm4-4h2v2h-2V7zm0 4h2v2h-2v-2zM7 15h2v2H7v-2zm4 0h2v2h-2v-2z"/></svg>
</div> </div>
<div class="col-span-2 font-mono"> <div class="w-16">
<div class="text-[11px] font-bold text-slate-300 mb-0.5">{{ s.date }}</div> <div class="text-[10px] font-bold text-slate-500 uppercase">{{ s.date }}</div>
<div class="text-sm font-black text-blue-500">{{ s.start }}</div> <div class="text-lg font-black">{{ s.start }}</div>
<div class="text-[10px] text-slate-500">{{ s.end }}</div>
</div> </div>
<div class="col-span-4"> <div class="flex-1">
<select onchange="updateStintDriver('{{ s.stint_id }}', this.value)" class="w-full bg-slate-900/80 border-slate-800 text-xs font-bold py-1"> <select onchange="updateStintDriver({{ s.id }}, this.value)" class="text-sm font-bold p-1 rounded">
{% for d in drivers %}<option value="{{ d.id }}" {% if d.id == s.driver_id %}selected{% endif %}>{{ d.name }}</option>{% endfor %} {% for d in drivers %}
<option value="{{ d.id }}" {{ 'selected' if d.id == s.driver_id else '' }}>{{ d.name }}</option>
{% endfor %}
</select> </select>
<div class="text-[10px] font-bold text-slate-500 uppercase mt-1">
{{ s.laps }} Runden · <span class="text-green-500">{{ s.fuel }}L</span>
</div> </div>
<div class="col-span-2 text-center">
<div class="font-black">{{ s.laps }} R</div>
<div class="text-[10px] text-green-500">{{ s.fuel }}L</div>
</div> </div>
<div class="col-span-2 text-right"> <div class="text-right">
{% if s.is_finish %}<span class="bg-blue-600 text-[8px] font-black px-2 py-1 rounded text-white uppercase">Finish</span> <div class="text-lg font-black">{{ s.end }}</div>
{% elif s.change_tires %}<span class="bg-green-600/20 text-green-500 text-[8px] font-black px-2 py-1 rounded border border-green-500/30 uppercase">Tires</span>{% endif %} {% if s.is_finish %}<span class="bg-blue-600 text-white text-[8px] font-black px-2 py-1 rounded uppercase">Finish</span>{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -128,14 +86,50 @@
</div> </div>
</div> </div>
<!-- Modals (Simple Replacement for alert/prompt) -->
<div id="scenario-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div class="glass max-w-md w-full p-8 rounded-3xl">
<h3 id="modal-title" class="text-xl font-black mb-4 uppercase italic">Szenario</h3>
<div id="modal-content" class="space-y-4"></div>
<div class="flex justify-end gap-4 mt-6">
<button onclick="closeModal()" class="text-xs font-bold uppercase text-slate-500">Abbrechen</button>
</div>
</div>
</div>
<script> <script>
function updateStintDriver(stintId, driverId) { function setTheme(theme) {
fetch('/update_stint_driver', { fetch('/update_theme', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({theme: theme})}).then(() => window.location.reload());
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({stint_id: stintId, driver_id: driverId})
}).then(() => window.location.reload());
} }
function updateStintDriver(stintId, driverId) {
fetch('/update_stint_driver', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({stint_id: stintId, driver_id: driverId})}).then(() => window.location.reload());
}
function saveScenarioPrompt() {
const name = prompt("Name für das Szenario:");
if(name) {
fetch('/save_scenario', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: name})})
.then(r => r.json()).then(() => alert("Gespeichert!"));
}
}
function loadScenarioList() {
fetch('/list_scenarios').then(r => r.json()).then(files => {
const list = files.map(f => `<button onclick="loadScenario('${f}')" class="w-full text-left p-3 hover:bg-blue-600 rounded-lg text-sm font-bold border border-white/5 mb-2">${f}</button>`).join('');
document.getElementById('modal-title').innerText = "Szenario Laden";
document.getElementById('modal-content').innerHTML = list || "Keine Szenarien gefunden.";
document.getElementById('scenario-modal').classList.remove('hidden');
});
}
function loadScenario(file) {
fetch('/load_scenario', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({filename: file})})
.then(() => window.location.reload());
}
function closeModal() { document.getElementById('scenario-modal').classList.add('hidden'); }
[1, 2].forEach(num => { [1, 2].forEach(num => {
const el = document.getElementById('stints-car-' + num); const el = document.getElementById('stints-car-' + num);
if(el) { if(el) {
@@ -143,11 +137,7 @@
handle: '.handle', animation: 150, handle: '.handle', animation: 150,
onEnd: function() { onEnd: function() {
const order = Array.from(el.children).map(item => item.dataset.id); const order = Array.from(el.children).map(item => item.dataset.id);
fetch('/reorder_stints', { fetch('/reorder_stints', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({order: order})}).then(() => window.location.reload());
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({order: order})
}).then(() => window.location.reload());
} }
}); });
} }

View File

@@ -5,20 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RacePlanner Login</title> <title>RacePlanner Login</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'Inter', sans-serif; }
</style>
</head> </head>
<body class="bg-gray-900 text-white flex items-center justify-center h-screen"> <body class="bg-slate-100 flex items-center justify-center h-screen">
<div class="bg-gray-800 p-8 rounded-2xl shadow-2xl w-full max-w-sm border border-gray-700"> <div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-sm border border-slate-200">
<h1 class="text-3xl font-black text-blue-500 mb-6 text-center italic">RACEPLANNER</h1> <h1 class="text-3xl font-black text-blue-600 mb-6 text-center italic">RACEPLANNER</h1>
<form action="/login" method="POST" class="space-y-4"> <form action="/login" method="POST" class="space-y-4">
<div> <div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Benutzer</label> <label class="block text-xs font-bold text-slate-400 uppercase mb-1">Benutzer</label>
<input type="text" name="username" class="w-full bg-gray-900 border-none rounded p-3" required> <input type="text" name="username" class="w-full bg-slate-50 border border-slate-200 rounded p-3 text-slate-900 focus:ring-2 ring-blue-500 outline-none" placeholder="z.B. mscaltenbach" required>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Passwort</label> <label class="block text-xs font-bold text-slate-400 uppercase mb-1">Passwort</label>
<input type="password" name="password" class="w-full bg-gray-900 border-none rounded p-3" required> <input type="password" name="password" class="w-full bg-slate-50 border border-slate-200 rounded p-3 text-slate-900 focus:ring-2 ring-blue-500 outline-none" required>
</div> </div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-500 font-bold py-3 rounded uppercase transition">Anmelden</button> <button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-black py-3 rounded-lg uppercase tracking-widest transition-all">Anmelden</button>
</form> </form>
</div> </div>
</body> </body>