Compare commits

...

1 Commits

Author SHA1 Message Date
blasebast 2ba9f3963e WIP: ha-dashboard, Prowlarr indexers, HA pyscript, log-ingester, watchdog
- Drop stale backups: frigate/config.yml.bak, timelapse_maker.py.bak (cleanup)
- ha-dashboard: app + niepalenie/rzucanie templates and bin helpers
- homeassistant: stefi_custody_days_calculator
- prowlarr: indexer YAML updates + scenetime-api
- log-ingester: state snapshot
- watchdog.sh

Made-with: Cursor
2026-04-09 11:58:27 +02:00
21 changed files with 1886 additions and 673 deletions

View File

@ -1,428 +0,0 @@
# ===================================================================
# Frigate Configuration v0.16.3 - Finalny config (Fixed Alerts)
# ===================================================================
mqtt:
host: 192.168.1.132
port: 1883
topic_prefix: frigate
client_id: frigate
user: mosquitto
password: guilermo3
stats_interval: 60
logger:
default: info
logs:
frigate.embeddings: error
frigate.embeddings.onnx: error
detectors:
coral:
type: edgetpu
device: usb
ffmpeg:
hwaccel_args: []
go2rtc:
streams:
birdseye:
- ffmpeg:birdseye#video=h264#audio=opus
rtsp:
transport: tcp
birdseye:
enabled: true
width: 1920
height: 1080
quality: 8
mode: continuous
detect:
enabled: true
width: 640
height: 360
fps: 7
semantic_search:
enabled: false
model_size: large
face_recognition:
enabled: false
model_size: large
lpr:
enabled: false
cameras:
frigate_reolink_1:
ffmpeg:
inputs:
- path:
rtsp://admin:{FRIGATE_REO_PW}@192.168.1.101:554/h264Preview_01_main
roles: [record]
- path:
rtsp://admin:{FRIGATE_REO_PW}@192.168.1.101:554/h264Preview_01_sub
roles: [detect]
detect:
enabled: true
width: 640
height: 360
fps: 8
objects:
track: [person, car]
filters:
person:
# POPRAWKA: Zmniejszono min_area i usunięto max_area, aby lepiej wykrywać ludzi
min_area: 1500
min_score: 0.75
car: {min_area: 3000, min_score: 0.80}
motion:
threshold: 25
contour_area: 80
improve_contrast: true
mask:
0.002,0.004,0.002,0.706,0.417,0.539,0.32,0.241,0.377,0.185,0.673,0.372,0.996,0.538,0.997,0.009
zones:
parking: {coordinates: '0.443,0.568,0.445,1,1,1,0.994,0.57,0.581,0.333',
inertia: 3, loitering_time: 5}
driveway: {coordinates: '0.325,0.252,0.423,0.535,0.453,0.529,0.575,0.322,0.437,0.232,0.38,0.201',
loitering_time: 5, inertia: 2}
r1_my-garden:
coordinates: 0.005,0.717,0.435,0.547,0.44,1,0,1
loitering_time: 0
objects:
- person
inertia: 1
snapshots: {enabled: true, timestamp: false, bounding_box: true}
record:
enabled: true
retain: {days: 10, mode: motion}
alerts: {retain: {days: 14}}
detections: {retain: {days: 14}}
review:
alerts:
labels: [person, car]
required_zones: []
detections:
labels: [person, car]
required_zones: []
frigate_reolink_3:
ffmpeg:
inputs:
- path: rtsp://admin:{FRIGATE_REO_PW}@192.168.1.150:554/Preview_01_main
roles: [record]
- path: rtsp://admin:{FRIGATE_REO_PW}@192.168.1.150:554/Preview_01_sub
roles: [detect]
detect:
enabled: true
width: 640
height: 360
fps: 7
max_disappeared: 30
stationary: {interval: 40, threshold: 30}
objects:
track: [person, car]
filters:
person: {min_area: 2000, max_area: 20000, min_score: 0.73}
car: {min_area: 4000, min_score: 0.7}
motion:
threshold: 25
contour_area: 80
improve_contrast: true
mask:
0.001,0.673,0.005,0.009,0.935,0.012,0.929,0.345,0.932,0.417,0.776,0.247,0.691,0.156,0.448,0.028
zones:
terrace:
coordinates: 0,0.966,0.149,0.898,0.275,0.625,0.46,1,0,1
inertia: 3
loitering_time: 10
kennel:
coordinates: 0,0.729,0.108,0.531,0.152,0.75,0.041,0.9
inertia: 4
loitering_time: 0
objects:
- person
my-garden:
coordinates:
0.179,0.799,0.135,0.547,0.451,0.036,0.685,0.157,0.932,0.433,0.992,0.978,0.482,0.991,0.39,0.852,0.274,0.606
loitering_time: 0
snapshots: {enabled: true, timestamp: false, bounding_box: true}
record:
enabled: true
retain: {days: 10, mode: motion}
alerts: {retain: {days: 14}}
detections: {retain: {days: 14}}
review:
alerts:
labels: [person]
required_zones: []
detections:
labels: [person]
required_zones: [terrace]
frigate_reolink_4:
ffmpeg:
inputs:
- path: rtsp://admin:{FRIGATE_REO_PW}@192.168.1.168:554/Preview_01_main
roles: [record]
- path: rtsp://admin:{FRIGATE_REO_PW}@192.168.1.168:554/Preview_01_sub
roles: [detect]
detect:
enabled: true
width: 640
height: 360
fps: 7
max_disappeared: 30
stationary: {interval: 40, threshold: 30}
objects:
track: [person, car]
filters:
person: {min_area: 1800, min_score: 0.78, mask: '0,0,1,0,1,0.15,0.7,0.15,0.5,0.12,0.32,0.05,0,0.05'}
car: {min_area: 3000, min_score: 0.7}
motion:
threshold: 25
contour_area: 80
improve_contrast: true
mask: 0,0,1,0,1,0.804,0.692,0.318,0.558,0.13,0.32,0.05,0,0.152
zones:
main_area: {coordinates: '0.37,0.091,0.519,0.139,0.822,1,0.199,1',
loitering_time: 10, inertia: 3}
entrance: {coordinates: '0.415,0.169,0.491,0.253,0.515,0.432,0.439,0.661',
objects: [person], inertia: 3, loitering_time: 0}
snapshots: {enabled: true, timestamp: false, bounding_box: true}
record:
enabled: true
retain: {days: 2, mode: motion}
alerts: {retain: {days: 2}}
detections: {retain: {days: 2}}
review:
alerts:
labels: [person]
required_zones: []
detections:
labels: [person]
required_zones: []
frigate_hikvision_4:
ffmpeg:
inputs:
- path: rtsp://admin:{FRIGATE_DOMOFON_HIK_PW}@192.168.1.127:554/Streaming/Channels/101
roles: [record]
- path: rtsp://admin:{FRIGATE_DOMOFON_HIK_PW}@192.168.1.127:554/Streaming/Channels/102
roles: [detect]
detect:
enabled: true
width: 640
height: 360
fps: 10
motion: {threshold: 50, contour_area: 40, improve_contrast: true, lightning_threshold: 0.8}
objects:
track: [person]
filters:
person: {min_score: 0.67, threshold: 0.70, min_area: 600, max_area: 250000, min_ratio: 0.20, max_ratio: 0.85}
snapshots: {enabled: true, timestamp: false, bounding_box: true}
record:
enabled: true
retain: {days: 10, mode: motion}
alerts: {retain: {days: 10}}
detections: {retain: {days: 10}}
zones:
street-left:
coordinates: 0.906,0.491,0.733,0.482,0.755,0.59,0.805,0.66,1,0.694,0.997,0.624,0.997,0.49
objects: [person]
inertia: 2
loitering_time: 0
robo:
coordinates: 0.513,0.371,0.504,0.796,0.655,0.867,0.821,0.814,0.904,0.777,0.952,0.689,0.806,0.675,0.75,0.594,0.717,0.42
objects: [person]
loitering_time: 0
inertia: 3
at-gate:
coordinates: 0,0,1,0,1,0.482,0.733,0.478,0.73,0.429,0.517,0.36,0.507,0.358,0.498,0.801,0.653,0.876,0.819,0.823,0.907,0.781,0.953,0.695,0.998,0.704,1,1,0,1
objects: [person]
loitering_time: 0
inertia: 3
review:
alerts:
labels: [person]
required_zones: []
detections:
labels: [person, car]
required_zones: []
frigate_reolink_2:
ffmpeg:
inputs:
- path:
rtsp://admin:{FRIGATE_REO_PW}@192.168.1.62:554/h264Preview_01_sub
roles: [detect, record]
detect:
enabled: true
width: 640
height: 480
fps: 8
max_disappeared: 30
stationary: {interval: 40, threshold: 30}
objects:
track: [person]
filters:
person: {min_area: 1000, min_score: 0.72}
car: {min_area: 2500, min_score: 0.7}
motion:
threshold: 22
contour_area: 80
improve_contrast: true
mask: 0.006,0.004,0,0.415,0.223,0.323,0.338,0.228,0.552,0.078,0.621,0
zones:
my_sidewalk:
coordinates:
0,0.415,0.225,0.325,0.303,0.265,0.551,0.082,0.621,0,0.7,0,0.973,1,0,1
inertia: 3
loitering_time: 8
snapshots: {enabled: true, timestamp: false, bounding_box: true}
record:
enabled: true
retain: {days: 10, mode: motion}
alerts: {retain: {days: 10}}
detections: {retain: {days: 10}}
review:
alerts:
labels: [person]
required_zones: []
detections:
labels: [person]
required_zones: []
frigate_reolink_5:
ffmpeg:
inputs:
- path: rtsp://admin:{FRIGATE_REO_PW}@192.168.1.172:554/Preview_01_sub
roles: [detect, record]
detect:
enabled: true
width: 640
height: 360
fps: 6
max_disappeared: 30
stationary: {interval: 40, threshold: 30}
objects:
track: [person, car]
filters:
person: {min_area: 1800, min_score: 0.75, mask: '0,0.95,0.12,0.72,0.2,0.4,0.35,0.22,0.5,0.18,0.85,0.22,1,0,0,0'}
car: {min_area: 3000, min_score: 0.7}
motion:
threshold: 25
contour_area: 80
improve_contrast: true
mask: 0,0.95,0.12,0.72,0.2,0.4,0.35,0.22,0.5,0.18,0.85,0.22,1,0,0,0
zones:
terrace:
coordinates:
0.442,0.214,0.626,0.218,0.669,0.215,0.819,0.243,0.788,0.669,0.763,1,0.038,1,0.131,0.775,0.238,0.465
inertia: 3
loitering_time: 10
snapshots: {enabled: true, timestamp: false, bounding_box: true}
record:
enabled: true
retain: {days: 2, mode: motion}
alerts: {retain: {days: 2}}
detections: {retain: {days: 2}}
review:
alerts:
labels: [person]
required_zones: []
detections:
labels: [person]
required_zones: []
frigate_hikvision_2:
ffmpeg:
inputs:
- path:
rtsp://admin:{FRIGATE_HIK_PW}@192.168.1.65:554/Streaming/Channels/102/
roles: [detect, record]
detect:
enabled: true
width: 640
height: 360
fps: 7
objects:
track: [person, car]
filters:
person:
min_score: 0.74
mask:
0.139,1,0.091,0.685,0.658,0.48,0.635,0.331,1,0.272,0.996,0.007,0.015,0.014,0.028,0.718
car:
min_score: 0.8
mask: 0.646,0.335,0.668,0.522,0.858,0.473,0.844,0.302
motion: {threshold: 25, contour_area: 150, improve_contrast: false}
snapshots: {enabled: true, timestamp: false, bounding_box: true}
zones:
my-garden:
coordinates:
0.651,0.477,0.404,0.577,0.421,0.666,0.094,0.795,0.054,0.99,0.902,0.997,0.859,0.42,0.845,0.278,0.622,0.298
inertia: 2
loitering_time: 0
objects:
- person
record:
enabled: true
retain: {days: 10, mode: motion}
alerts: {retain: {days: 10}}
detections: {retain: {days: 10}}
review:
alerts:
labels: [person]
required_zones: []
detections:
labels: [person]
required_zones: []
mercusys_mc200:
ffmpeg:
inputs:
- path:
rtsp://{FRIGATE_MERCUSYS_USER}:{FRIGATE_MERCUSYS_PW}@192.168.20.33:554/stream2
roles: [detect, record]
detect:
enabled: true
width: 640
height: 360
fps: 6
objects:
track: [person, cat]
filters:
person: {min_area: 1500, max_area: 20000, min_score: 0.75}
cat: {min_area: 300, max_area: 6000, min_score: 0.73}
motion: {threshold: 25, contour_area: 80, improve_contrast: true}
record:
enabled: true
retain: {days: 7, mode: motion}
alerts: {retain: {days: 7}}
detections: {retain: {days: 7}}
snapshots:
enabled: true
timestamp: true
retain: {default: 3}
review:
alerts:
labels: [person, cat]
required_zones: []
detections:
labels: [person, cat]
required_zones: []
snapshots:
retain:
default: 365
objects:
person: 3650
car: 180
quality: 90
clean_copy: true
version: 0.16-0

View File

@ -732,6 +732,111 @@ def api_zigbee():
return jsonify({"error": str(e)}), 500
@app.route("/niepalenie")
def niepalenie():
return render_template("niepalenie.html")
# ── Rzucanie - persistent profiles ────────────────────────────────────────────
import sqlite3 as _sqlite3
import secrets as _secrets
_RZ_DB = os.path.join(AUTH_DIR, "rzucanie.db")
def _rz_conn():
conn = _sqlite3.connect(_RZ_DB)
conn.row_factory = _sqlite3.Row
return conn
def _rz_init():
with _rz_conn() as c:
c.execute("""
CREATE TABLE IF NOT EXISTS profiles (
token TEXT PRIMARY KEY,
name TEXT DEFAULT '',
dziennie REAL NOT NULL,
paczka REAL NOT NULL DEFAULT 20,
cena REAL NOT NULL,
rzucone TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
_rz_init()
def _rz_get(token):
with _rz_conn() as c:
row = c.execute("SELECT * FROM profiles WHERE token = ?", (token,)).fetchone()
return dict(row) if row else None
@app.route("/rzucanie", methods=["GET", "POST"])
def rzucanie_setup():
if request.method == "POST":
try:
dziennie = float(request.form["dziennie"])
paczka = float(request.form["paczka"])
cena = float(request.form["cena"])
rzucone = request.form["rzucone"] # datetime-local string
name = request.form.get("name", "").strip()
except (KeyError, ValueError):
return render_template("rzucanie.html", config=None, error="Uzupełnij wszystkie pola.")
token = _secrets.token_urlsafe(8)
with _rz_conn() as c:
c.execute(
"INSERT INTO profiles (token,name,dziennie,paczka,cena,rzucone,created_at) VALUES (?,?,?,?,?,?,?)",
(token, name, dziennie, paczka, cena, rzucone, datetime.utcnow().isoformat()),
)
return redirect(url_for("rzucanie_dash", token=token))
return render_template("rzucanie.html", config=None, error=None)
@app.route("/rzucanie/<token>", methods=["GET"])
def rzucanie_dash(token):
config = _rz_get(token)
if not config:
return redirect(url_for("rzucanie_setup"))
return render_template("rzucanie.html", config=config, token=token, error=None)
@app.route("/api/rzucanie/profiles")
@login_required
def rzucanie_profiles():
with _rz_conn() as c:
rows = c.execute(
"SELECT token, name, dziennie, cena, rzucone, created_at FROM profiles ORDER BY created_at DESC"
).fetchall()
return jsonify([dict(r) for r in rows])
@app.route("/api/rzucanie/<token>", methods=["DELETE"])
@login_required
def rzucanie_delete(token):
with _rz_conn() as c:
c.execute("DELETE FROM profiles WHERE token = ?", (token,))
return jsonify({"ok": True})
@app.route("/api/rzucanie/<token>", methods=["POST"])
def rzucanie_update(token):
if not _rz_get(token):
return jsonify({"error": "not found"}), 404
data = request.get_json(force=True)
fields = {k: data[k] for k in ("name", "dziennie", "paczka", "cena", "rzucone") if k in data}
if not fields:
return jsonify({"error": "no fields"}), 400
set_clause = ", ".join(f"{k} = ?" for k in fields)
with _rz_conn() as c:
c.execute(f"UPDATE profiles SET {set_clause} WHERE token = ?",
(*fields.values(), token))
return jsonify(_rz_get(token))
@app.route("/")
@login_required
def index():

View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Usuwa profil rzucania palenia po tokenie
# Użycie: rzucanie-delete TOKEN [URL]
# Zmienne środowiskowe: HADASH_USER, HADASH_PASS, HADASH_URL
set -euo pipefail
TOKEN="${1:-}"
URL="${2:-${HADASH_URL:-http://localhost:9081}}"
USER="${HADASH_USER:-}"
PASS="${HADASH_PASS:-}"
COOKIE=$(mktemp /tmp/hadash-cookie.XXXXXX)
trap 'rm -f "$COOKIE"' EXIT
if [[ -z "$TOKEN" ]]; then
echo "Użycie: $(basename "$0") TOKEN [URL]" >&2
exit 1
fi
if [[ -z "$USER" ]]; then
read -rp "Login: " USER
fi
if [[ -z "$PASS" ]]; then
read -rsp "Hasło: " PASS; echo
fi
# Login (-d implikuje POST; -L śledzi redirect do / już jako GET)
HTTP=$(curl -s -o /dev/null -w "%{http_code}" -L -c "$COOKIE" -b "$COOKIE" \
"$URL/login" \
-d "username=${USER}&password=${PASS}")
if [[ "$HTTP" != "200" ]]; then
echo "Błąd logowania (HTTP $HTTP)" >&2
exit 1
fi
# Usuń profil
RESULT=$(curl -s -b "$COOKIE" -X DELETE "$URL/api/rzucanie/${TOKEN}")
if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('ok') else 1)" 2>/dev/null; then
echo "Usunięto profil: $TOKEN"
else
echo "Błąd: $RESULT" >&2
exit 1
fi

57
ha-dashboard/bin/rzucanie-list Executable file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Lista profili rzucania palenia
# Użycie: rzucanie-list [URL]
# Zmienne środowiskowe: HADASH_USER, HADASH_PASS, HADASH_URL
set -euo pipefail
URL="${1:-${HADASH_URL:-http://localhost:9081}}"
USER="${HADASH_USER:-}"
PASS="${HADASH_PASS:-}"
COOKIE=$(mktemp /tmp/hadash-cookie.XXXXXX)
trap 'rm -f "$COOKIE"' EXIT
if [[ -z "$USER" ]]; then
read -rp "Login: " USER
fi
if [[ -z "$PASS" ]]; then
read -rsp "Hasło: " PASS; echo
fi
# Login (-d implikuje POST; -L śledzi redirect do / już jako GET)
HTTP=$(curl -s -o /dev/null -w "%{http_code}" -L -c "$COOKIE" -b "$COOKIE" \
"$URL/login" \
-d "username=${USER}&password=${PASS}")
if [[ "$HTTP" != "200" ]]; then
echo "Błąd logowania (HTTP $HTTP)" >&2
exit 1
fi
# Pobierz profile
curl -s -b "$COOKIE" "$URL/api/rzucanie/profiles" | python3 -c "
import sys, json
from datetime import datetime, timezone
profiles = json.load(sys.stdin)
if 'error' in profiles:
print('Błąd:', profiles['error'])
sys.exit(1)
if not profiles:
print('Brak profili.')
sys.exit(0)
print(f' {len(profiles)} profil(e/ów):')
print()
print(f' {\"TOKEN\":<14} {\"IMIĘ\":<14} {\"RZUCIŁ/A\":<17} {\"REJESTRACJA\":<17} {\"DZIENNIE\":>9} {\"CENA\":>8}')
print(' ' + '-'*90)
for p in profiles:
name = (p.get('name') or '(brak)') [:14]
token = p['token'][:14]
rzucone = p['rzucone'][:16].replace('T', ' ')
created = p['created_at'][:16].replace('T', ' ')
dziennie = f\"{p['dziennie']:.0f} szt.\"
cena = f\"{p['cena']:.2f} zł\"
print(f' {token:<14} {name:<14} {rzucone:<17} {created:<17} {dziennie:>9} {cena:>8}')
"

View File

@ -0,0 +1,705 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Nie palę 🚭</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚭</text></svg>">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f172a; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; font-size: 13px; }
header {
padding: 13px 20px; background: #1e293b; border-bottom: 1px solid #334155;
display: flex; align-items: center; gap: 16px;
}
header a { color: #64748b; text-decoration: none; font-size: 12px; }
header a:hover { color: #e2e8f0; }
header h1 { font-size: 16px; font-weight: 600; color: #f1f5f9; }
main {
display: grid; grid-template-columns: repeat(12, 1fr);
gap: 14px; padding: 16px 20px; max-width: 1600px; margin: 0 auto;
}
.col-3 { grid-column: span 3; }
.col-4 { grid-column: span 4; }
.col-5 { grid-column: span 5; }
.col-6 { grid-column: span 6; }
.col-7 { grid-column: span 7; }
.col-8 { grid-column: span 8; }
.col-12 { grid-column: span 12; }
@media (max-width: 1100px) {
.col-3,.col-4,.col-5,.col-6,.col-7,.col-8 { grid-column: span 12; }
}
.card { background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 15px; }
.card-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: #64748b; margin-bottom: 12px;
}
/* ── HERO ── */
.hero {
background: linear-gradient(135deg, #1e293b 0%, #0f2027 50%, #1e293b 100%);
border: 1px solid #334155; border-radius: 10px; padding: 28px 24px;
text-align: center; position: relative; overflow: hidden;
}
.hero::before {
content: '🚭';
position: absolute; font-size: 120px; opacity: .04;
top: 50%; left: 50%; transform: translate(-50%,-50%);
pointer-events: none; user-select: none;
}
.hero-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .12em; color: #64748b; margin-bottom: 6px; }
.hero-main { font-size: 15px; font-weight: 700; color: #f1f5f9; margin-bottom: 18px; }
.hero-main span { color: #f87171; }
.counter-row {
display: flex; justify-content: center; gap: 16px; flex-wrap: wrap;
}
.counter-unit {
background: rgba(15,23,42,.7); border: 1px solid #334155; border-radius: 10px;
padding: 14px 20px; min-width: 90px;
}
.counter-val { font-size: 36px; font-weight: 700; color: #f1f5f9; line-height: 1; font-variant-numeric: tabular-nums; }
.counter-lbl { font-size: 10px; color: #64748b; margin-top: 5px; text-transform: uppercase; letter-spacing: .08em; }
/* ── STAT CARDS ── */
.stat-big { text-align: center; padding: 8px 0; }
.stat-big .val { font-size: 32px; font-weight: 700; line-height: 1.1; }
.stat-big .lbl { font-size: 11px; color: #64748b; margin-top: 5px; }
.stat-big .sub { font-size: 10px; color: #475569; margin-top: 3px; }
.green { color: #34d399; }
.red { color: #f87171; }
.amber { color: #fbbf24; }
.blue { color: #60a5fa; }
.violet{ color: #a78bfa; }
/* ── MILESTONE ── */
.milestone-wrap { display: flex; align-items: center; gap: 20px; }
.milestone-chart { flex-shrink: 0; width: 160px; height: 160px; position: relative; }
.milestone-center {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
text-align: center; pointer-events: none;
}
.milestone-center .pct { font-size: 24px; font-weight: 700; color: #f1f5f9; }
.milestone-center .lbl { font-size: 10px; color: #64748b; }
.milestone-list { flex: 1; display: flex; flex-direction: column; gap: 7px; }
.ms-item { display: flex; align-items: center; gap: 8px; }
.ms-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.ms-done { background: #34d399; }
.ms-next { background: #fbbf24; }
.ms-future { background: #334155; }
.ms-label { font-size: 11px; color: #94a3b8; flex: 1; }
.ms-label.done { color: #34d399; }
.ms-label.next { color: #fbbf24; font-weight: 600; }
.ms-bar-bg { width: 70px; background: #0f172a; border-radius: 3px; height: 5px; overflow: hidden; }
.ms-bar-fill { height: 100%; border-radius: 3px; background: #fbbf24; transition: width .5s; }
/* ── SAVINGS CHART ── */
.chart-wrap { height: 200px; position: relative; }
/* ── TIMELINE ── */
.timeline { display: flex; flex-direction: column; gap: 0; }
.tl-item { display: flex; gap: 14px; align-items: stretch; padding-bottom: 16px; }
.tl-item:last-child { padding-bottom: 0; }
.tl-left { display: flex; flex-direction: column; align-items: center; width: 32px; flex-shrink: 0; }
.tl-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; margin-top: 2px; }
.tl-line { flex: 1; width: 2px; background: #334155; margin-top: 4px; }
.tl-item:last-child .tl-line { display: none; }
.tl-content {}
.tl-date { font-size: 10px; color: #64748b; margin-bottom: 2px; }
.tl-title { font-size: 13px; font-weight: 600; color: #f1f5f9; }
.tl-desc { font-size: 11px; color: #64748b; margin-top: 2px; }
/* ── SAVINGS DETAIL ── */
.savings-row { display: flex; justify-content: space-between; padding: 6px 0;
border-bottom: 1px solid #1e293b; font-size: 12px; }
.savings-row:last-child { border-bottom: none; }
.savings-row .sr-label { color: #94a3b8; }
.savings-row .sr-val { color: #fbbf24; font-weight: 600; }
</style>
</head>
<body>
<header>
<a href="/">← Dashboard</a>
<h1>🚭 Nie palę</h1>
</header>
<main>
<!-- HERO counter -->
<div class="hero col-12">
<div class="hero-title">Rzuciłem palenie</div>
<div class="hero-main">23 sierpnia 2011 · <span>z przerwą w 2021</span></div>
<div class="counter-row">
<div class="counter-unit">
<div class="counter-val" id="c-lat"></div>
<div class="counter-lbl">lat</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-dni"></div>
<div class="counter-lbl">dni</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-godz"></div>
<div class="counter-lbl">godzin</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-min"></div>
<div class="counter-lbl">minut</div>
</div>
<div class="counter-unit">
<div class="counter-val" id="c-sek"></div>
<div class="counter-lbl">sekund</div>
</div>
</div>
</div>
<!-- Savings big -->
<div class="card col-3">
<div class="card-title">Zaoszczędzono</div>
<div class="stat-big">
<div class="val amber" id="st-zaoszcz"></div>
<div class="lbl">złotych</div>
<div class="sub" id="st-na-godz"></div>
</div>
</div>
<!-- Not smoked -->
<div class="card col-3">
<div class="card-title">Nie wypaliłem</div>
<div class="stat-big">
<div class="val green" id="st-nie-wypalil"></div>
<div class="lbl">papierosów</div>
<div class="sub" id="st-tyg"> zł/tyg · zł/mies</div>
</div>
</div>
<!-- Smoked total -->
<div class="card col-3">
<div class="card-title">Wypaliłem łącznie</div>
<div class="stat-big">
<div class="val red" id="st-wypalil"></div>
<div class="lbl">papierosów</div>
<div class="sub" id="st-wypalil-sub"> przed 2011 · nawrót</div>
</div>
</div>
<!-- Next jubilee -->
<div class="card col-3">
<div class="card-title">Następny jubileusz</div>
<div class="stat-big">
<div class="val violet" id="st-jub-label"></div>
<div class="lbl" id="st-jub-pct"></div>
<div class="sub" id="st-jub-rem">za dni</div>
</div>
</div>
<!-- Milestone doughnut + list -->
<div class="card col-5">
<div class="card-title">Kamienie milowe</div>
<div class="milestone-wrap">
<div class="milestone-chart">
<canvas id="jubChart"></canvas>
<div class="milestone-center">
<div class="pct" id="jub-pct-center">%</div>
<div class="lbl" id="jub-label-center"></div>
</div>
</div>
<div class="milestone-list" id="milestone-list">
<!-- filled by JS -->
</div>
</div>
</div>
<!-- Savings over time chart -->
<div class="card col-7">
<div class="card-title">Oszczędności w czasie (zł)</div>
<div class="chart-wrap">
<canvas id="savingsChart"></canvas>
</div>
</div>
<!-- Savings detail -->
<div class="card col-4">
<div class="card-title">Szczegóły oszczędności</div>
<div id="savings-detail">
<div class="savings-row"><span class="sr-label">Na godzinę</span><span class="sr-val" id="sd-godz"></span></div>
<div class="savings-row"><span class="sr-label">Dziennie</span><span class="sr-val" id="sd-dz"></span></div>
<div class="savings-row"><span class="sr-label">Tygodniowo</span><span class="sr-val" id="sd-tyg"></span></div>
<div class="savings-row"><span class="sr-label">Miesięcznie</span><span class="sr-val" id="sd-mies"></span></div>
<div class="savings-row"><span class="sr-label">Rocznie</span><span class="sr-val" id="sd-rok"></span></div>
<div class="savings-row"><span class="sr-label">Cena paczki (dziś)</span><span class="sr-val" id="sd-cena"></span></div>
<div class="savings-row"><span class="sr-label">Paliłem dziennie</span><span class="sr-val">19 szt.</span></div>
<div class="savings-row" style="border-top:1px solid #334155;margin-top:4px;padding-top:8px">
<span class="sr-label">Równowartość CZK</span>
<span class="sr-val blue" id="sd-czk"></span>
</div>
<div class="savings-row">
<span class="sr-label" style="color:#334155;font-size:10px" id="sd-czk-rate">kurs: </span>
<span class="sr-val" style="color:#334155;font-size:10px" id="sd-czk-note"></span>
</div>
</div>
</div>
<!-- Timeline -->
<div class="card col-4">
<div class="card-title">Historia</div>
<div class="timeline">
<div class="tl-item">
<div class="tl-left">
<div class="tl-dot" style="background:#64748b"></div>
<div class="tl-line"></div>
</div>
<div class="tl-content">
<div class="tl-date">19.11.1997</div>
<div class="tl-title">Pierwszy papieros</div>
<div class="tl-desc">Zaczął się nałóg</div>
</div>
</div>
<div class="tl-item">
<div class="tl-left">
<div class="tl-dot" style="background:#34d399"></div>
<div class="tl-line"></div>
</div>
<div class="tl-content">
<div class="tl-date">23.08.2011 · 01:01 UTC</div>
<div class="tl-title" style="color:#34d399">🚭 Rzuciłem (1. raz)</div>
<div class="tl-desc" id="tl-okres1"> dni bez papierosa</div>
</div>
</div>
<div class="tl-item">
<div class="tl-left">
<div class="tl-dot" style="background:#f87171"></div>
<div class="tl-line"></div>
</div>
<div class="tl-content">
<div class="tl-date">01.02.2021</div>
<div class="tl-title" style="color:#f87171">💨 Nawrót</div>
<div class="tl-desc" id="tl-nawrot"> dni nawrotu</div>
</div>
</div>
<div class="tl-item">
<div class="tl-left">
<div class="tl-dot" style="background:#34d399"></div>
<div class="tl-line"></div>
</div>
<div class="tl-content">
<div class="tl-date">25.12.2021</div>
<div class="tl-title" style="color:#34d399">🚭 Rzuciłem (2. raz)</div>
<div class="tl-desc" id="tl-okres2"> dni bez papierosa</div>
</div>
</div>
<div class="tl-item">
<div class="tl-left">
<div class="tl-dot" style="background:#a78bfa"></div>
</div>
<div class="tl-content">
<div class="tl-date">dziś</div>
<div class="tl-title" style="color:#a78bfa">🎯 Teraz</div>
<div class="tl-desc" id="tl-teraz">łącznie dni</div>
</div>
</div>
</div>
</div>
<!-- Periods breakdown -->
<div class="card col-4">
<div class="card-title">Okresy niepalenia</div>
<div id="periods-detail">
<div class="savings-row">
<span class="sr-label">Okres 1 (2011-2021)</span>
<span class="sr-val green" id="pd-o1"></span>
</div>
<div class="savings-row">
<span class="sr-label">Nawrót (2021)</span>
<span class="sr-val red" id="pd-naw"></span>
</div>
<div class="savings-row">
<span class="sr-label">Okres 2 (2021-teraz)</span>
<span class="sr-val green" id="pd-o2"></span>
</div>
<div class="savings-row">
<span class="sr-label">Łącznie bez papierosa</span>
<span class="sr-val violet" id="pd-total"></span>
</div>
<div class="savings-row">
<span class="sr-label">Palenie ogółem (1997-2011)</span>
<span class="sr-val" style="color:#64748b" id="pd-palil"> lat</span>
</div>
</div>
</div>
</main>
<script>
const MILESTONES = [
[30, "1 miesiąc"],
[90, "3 miesiące"],
[180, "pół roku"],
[365, "1 rok"],
[548, "1.5 roku"],
[730, "2 lata"],
[1095, "3 lata"],
[1461, "4 lata"],
[1826, "5 lat"],
[2557, "7 lat"],
[3652, "10 lat"],
[5479, "15 lat"],
[7305, "20 lat"],
];
// Config
const RZUCONE_TS = 1314061260; // 2011-08-23 01:01 UTC
const NAWROT_START = new Date("2021-02-01T00:00:00Z");
const NAWROT_KONIEC = new Date("2021-12-25T00:00:00Z");
const DZIENNIE = 19;
const W_PACZCE = 20;
const SEKUND_DOBY = 86400;
const RZUCONE_DATE = new Date(RZUCONE_TS * 1000);
const NAWROT_SEC = (NAWROT_KONIEC - NAWROT_START) / 1000;
// Historyczne ceny paczki papierosów w Polsce (PLN/20szt.)
// Źródło: MF (średnia ważona cena detaliczna) + zmiany stawek akcyzy
const PRICE_SCHEDULE = [
[new Date("2011-08-01"), 9.50], // rzucenie - ~WAP 2011
[new Date("2012-01-01"), 10.50], // +4% akcyzy
[new Date("2013-01-01"), 11.00], // +5% akcyzy
[new Date("2014-01-01"), 12.00], // +5% akcyzy
[new Date("2015-01-01"), 12.90], // +5% akcyzy
[new Date("2016-01-01"), 13.30], // stabilizacja
[new Date("2017-01-01"), 13.60], // brak podwyżki akcyzy
[new Date("2018-01-01"), 13.75], // brak podwyżki akcyzy
[new Date("2019-01-01"), 14.00], // koniec okresu zamrożenia
[new Date("2020-01-01"), 15.00], // +10% akcyzy
[new Date("2021-01-01"), 15.00], // stabilizacja (~nawrót)
[new Date("2022-01-01"), 15.75], // +2% akcyzy
[new Date("2023-01-01"), 16.50], // +2% akcyzy
[new Date("2024-01-01"), 18.00], // +10% akcyzy
[new Date("2025-03-01"), 20.50], // +25% akcyzy (1 marca 2025)
[new Date("2026-01-01"), 24.00], // +20% akcyzy (1 stycznia 2026)
];
// Cena paczki w danym momencie (step interpolation)
function priceAt(date) {
let price = PRICE_SCHEDULE[0][1];
for (const [d, p] of PRICE_SCHEDULE) {
if (d <= date) price = p;
else break;
}
return price;
}
// Aktualna cena i koszt dzienny (do wyświetlania stawek)
const CURRENT_PRICE = priceAt(new Date());
const KOSZT_DZIENNY_NOW = (DZIENNIE / W_PACZCE) * CURRENT_PRICE;
// Całkowanie oszczędności na przedziale dat (pomija nawrót)
function integrateSavings(from, to) {
if (from >= to) return 0;
let total = 0;
// Okres 1: przed nawrotem
const p1end = to < NAWROT_START ? to : NAWROT_START;
if (from < p1end) total += integrateSegment(from, p1end);
// Okres 2: po nawrocie
if (to > NAWROT_KONIEC) {
const p2start = from > NAWROT_KONIEC ? from : NAWROT_KONIEC;
total += integrateSegment(p2start, to);
}
return total;
}
function integrateSegment(from, to) {
// Sumuje po miesiącach
let total = 0;
let d = new Date(from.getFullYear(), from.getMonth(), 1);
while (d < to) {
const nextMonth = new Date(d.getFullYear(), d.getMonth() + 1, 1);
const start = d < from ? from : d;
const end = nextMonth < to ? nextMonth : to;
const days = (end - start) / 86400000;
total += days * (DZIENNIE / W_PACZCE) * priceAt(d);
d = nextMonth;
}
return total;
}
// Pre-obliczone oszczędności do początku dzisiejszego dnia (dla wydajności live)
const _TODAY_START = (function() {
const d = new Date(); d.setHours(0,0,0,0); return d;
})();
const _SAVINGS_TO_TODAY = integrateSavings(RZUCONE_DATE, _TODAY_START);
const _COST_PER_SEC_NOW = (DZIENNIE / W_PACZCE) * CURRENT_PRICE / SEKUND_DOBY;
function currentSavings() {
const now = new Date();
// Dzisiaj w nawrocie? (historycznie niemożliwe, ale dla poprawności)
if (now >= NAWROT_START && now <= NAWROT_KONIEC) return _SAVINGS_TO_TODAY;
const secsToday = (now - _TODAY_START) / 1000;
return _SAVINGS_TO_TODAY + secsToday * _COST_PER_SEC_NOW;
}
function calcStats(now) {
let o1, o2;
if (now <= NAWROT_START) {
// Przed nawrotem: czas od rzucenia do 'now'
o1 = Math.max(0, (now - RZUCONE_DATE) / 1000);
o2 = 0;
} else {
// Po początku nawrotu: okres1 stały + okres2 od końca nawrotu
o1 = (NAWROT_START - RZUCONE_DATE) / 1000;
o2 = Math.max(0, (now - NAWROT_KONIEC) / 1000);
}
const s = o1 + o2;
const d = s / SEKUND_DOBY;
return { s, d, o1, o2, nawrot: NAWROT_SEC };
}
function fmt(n) { return n.toLocaleString('pl-PL', {maximumFractionDigits:0}); }
function fmtPln(n) { return n.toLocaleString('pl-PL', {minimumFractionDigits:2, maximumFractionDigits:2}) + " zł"; }
// Decompose total seconds into lat/dni/h/min/sek (not calendar years - just approx)
function decompose(totalS) {
const SEC_ROK = 365.25 * SEKUND_DOBY;
const lat = Math.floor(totalS / SEC_ROK);
let rem = totalS % SEC_ROK;
const dni = Math.floor(rem / SEKUND_DOBY);
rem = rem % SEKUND_DOBY;
const godz = Math.floor(rem / 3600);
rem = rem % 3600;
const min = Math.floor(rem / 60);
const sek = Math.floor(rem % 60);
return { lat, dni, godz, min, sek };
}
// ── Charts ─────────────────────────────────────────────────────────────────
let jubChartInst = null;
let savingsChartInst = null;
let milestonesRendered = false;
function buildJubChart(pct) {
const ctx = document.getElementById('jubChart').getContext('2d');
const data = [Math.min(pct, 100), Math.max(0, 100 - pct)];
if (jubChartInst) {
jubChartInst.data.datasets[0].data = data;
jubChartInst.update('none');
return;
}
jubChartInst = new Chart(ctx, {
type: 'doughnut',
data: {
datasets: [{
data,
backgroundColor: ['#fbbf24', '#1e3a2f'],
borderWidth: 0,
borderRadius: 4,
}]
},
options: {
cutout: '72%',
plugins: { legend: { display: false }, tooltip: { enabled: false } },
animation: { duration: 600 },
}
});
}
function buildMilestoneList(totalDays) {
if (milestonesRendered) return;
milestonesRendered = true;
const el = document.getElementById('milestone-list');
el.innerHTML = '';
let prevDays = 0;
for (const [days, label] of MILESTONES) {
const done = totalDays >= days;
const isNext = !done && !el.querySelector('.next');
const pct = done ? 100 : (isNext ? Math.min(100, (totalDays - prevDays) / (days - prevDays) * 100) : 0);
const div = document.createElement('div');
div.className = 'ms-item';
const dotCls = done ? 'ms-done' : (isNext ? 'ms-next' : 'ms-future');
const lblCls = done ? 'done' : (isNext ? 'next' : '');
div.innerHTML = `
<div class="ms-dot ${dotCls}"></div>
<div class="ms-label ${lblCls}">${done ? '✓ ' : ''}${label}</div>
<div class="ms-bar-bg"><div class="ms-bar-fill" style="width:${pct.toFixed(1)}%"></div></div>
`;
el.appendChild(div);
prevDays = days;
}
}
function buildSavingsChart() {
if (savingsChartInst) return;
const ctx = document.getElementById('savingsChart').getContext('2d');
// Generate monthly data points using integrated (historical) prices
const labels = [], data = [];
const NOW = new Date();
let d = new Date(RZUCONE_DATE.getFullYear(), RZUCONE_DATE.getMonth(), 1);
while (d <= NOW) {
const nextMonth = new Date(d.getFullYear(), d.getMonth() + 1, 1);
const monthEnd = nextMonth <= NOW ? nextMonth : NOW;
labels.push(d.toLocaleDateString('pl-PL', { year: '2-digit', month: 'short' }));
data.push(parseFloat(integrateSavings(RZUCONE_DATE, monthEnd).toFixed(2)));
d = nextMonth;
}
savingsChartInst = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
data,
borderColor: '#34d399',
backgroundColor: 'rgba(52,211,153,.08)',
borderWidth: 2,
pointRadius: 0,
tension: 0.3,
fill: true,
segment: {
borderColor: ctx2 => {
const date = new Date(RZUCONE_DATE.getFullYear(), RZUCONE_DATE.getMonth() + ctx2.p1DataIndex, 1);
return (date >= NAWROT_START && date <= NAWROT_KONIEC) ? '#f87171' : '#34d399';
},
backgroundColor: ctx2 => {
const date = new Date(RZUCONE_DATE.getFullYear(), RZUCONE_DATE.getMonth() + ctx2.p1DataIndex, 1);
return (date >= NAWROT_START && date <= NAWROT_KONIEC)
? 'rgba(248,113,113,.06)' : 'rgba(52,211,153,.08)';
},
}
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index', intersect: false,
callbacks: {
label: ctx2 => ' ' + ctx2.raw.toLocaleString('pl-PL', {minimumFractionDigits:2}) + ' zł'
}
}
},
scales: {
x: { ticks: { color: '#475569', maxRotation: 0, maxTicksLimit: 10 }, grid: { color: '#1e293b' } },
y: { ticks: { color: '#475569', callback: v => v.toLocaleString('pl-PL') + ' zł' }, grid: { color: '#1e293b' } }
},
animation: { duration: 400 }
}
});
}
// ── Jubilee progress ─────────────────────────────────────────────────────────
function jubInfo(totalDays) {
let prevDays = 0;
for (const [days, label] of MILESTONES) {
if (totalDays < days) {
const pct = (totalDays - prevDays) / (days - prevDays) * 100;
return { label, pct: Math.max(0, Math.min(100, pct)), remDays: days - totalDays };
}
prevDays = days;
}
return { label: "20+ lat", pct: 100, remDays: 0 };
}
// ── Update DOM ───────────────────────────────────────────────────────────────
function update() {
const now = new Date();
const st = calcStats(now);
const d = st.d;
const s = st.s;
const dc = decompose(s);
// Hero counter
document.getElementById('c-lat').textContent = dc.lat;
document.getElementById('c-dni').textContent = String(dc.dni).padStart(3, ' ');
document.getElementById('c-godz').textContent = String(dc.godz).padStart(2, '0');
document.getElementById('c-min').textContent = String(dc.min).padStart(2, '0');
document.getElementById('c-sek').textContent = String(dc.sek).padStart(2, '0');
const zaoszcz = currentSavings();
const nieWypalil = Math.floor(DZIENNIE * d);
document.getElementById('st-zaoszcz').textContent = fmtPln(zaoszcz);
document.getElementById('st-na-godz').textContent = fmtPln(KOSZT_DZIENNY_NOW / 24) + '/h';
document.getElementById('st-nie-wypalil').textContent = fmt(nieWypalil);
document.getElementById('st-tyg').textContent = fmtPln(KOSZT_DZIENNY_NOW * 7) + '/tyg';
const wypalilPrzed = Math.floor((RZUCONE_DATE - new Date(879897600*1000)) / 1000 / SEKUND_DOBY * 15);
const wypalilNawrot = Math.floor(st.nawrot / SEKUND_DOBY * DZIENNIE);
const wypalilTotal = wypalilPrzed + wypalilNawrot;
document.getElementById('st-wypalil').textContent = fmt(wypalilTotal);
document.getElementById('st-wypalil-sub').textContent = fmt(wypalilPrzed) + ' + ' + fmt(wypalilNawrot) + ' nawrót';
const jub = jubInfo(d);
document.getElementById('st-jub-label').textContent = jub.label;
document.getElementById('st-jub-pct').textContent = jub.pct.toFixed(1) + '%';
document.getElementById('st-jub-rem').textContent = 'za ' + Math.ceil(jub.remDays) + ' dni';
document.getElementById('jub-pct-center').textContent = jub.pct.toFixed(0) + '%';
document.getElementById('jub-label-center').textContent = jub.label;
// Savings detail (stawki wg aktualnej ceny paczki)
document.getElementById('sd-godz').textContent = fmtPln(KOSZT_DZIENNY_NOW / 24);
document.getElementById('sd-dz').textContent = fmtPln(KOSZT_DZIENNY_NOW);
document.getElementById('sd-tyg').textContent = fmtPln(KOSZT_DZIENNY_NOW * 7);
document.getElementById('sd-mies').textContent = fmtPln(KOSZT_DZIENNY_NOW * 30.44);
document.getElementById('sd-rok').textContent = fmtPln(KOSZT_DZIENNY_NOW * 365.25);
document.getElementById('sd-cena').textContent = fmtPln(CURRENT_PRICE);
const czk = zaoszcz * CZK_RATE;
document.getElementById('sd-czk').textContent =
czk.toLocaleString('cs-CZ', {minimumFractionDigits:0, maximumFractionDigits:0}) + ' Kč';
// Timeline
document.getElementById('tl-okres1').textContent = Math.round(st.o1 / SEKUND_DOBY) + ' dni bez papierosa';
document.getElementById('tl-nawrot').textContent = Math.round(st.nawrot / SEKUND_DOBY) + ' dni nawrotu';
document.getElementById('tl-okres2').textContent = Math.round(st.o2 / SEKUND_DOBY) + ' dni bez papierosa';
document.getElementById('tl-teraz').textContent = 'łącznie ' + Math.floor(d) + ' dni';
// Periods detail
document.getElementById('pd-o1').textContent = Math.round(st.o1 / SEKUND_DOBY) + ' dni';
document.getElementById('pd-naw').textContent = Math.round(st.nawrot / SEKUND_DOBY) + ' dni';
document.getElementById('pd-o2').textContent = Math.round(st.o2 / SEKUND_DOBY) + ' dni';
document.getElementById('pd-total').textContent = Math.floor(d) + ' dni';
const latPalil = ((RZUCONE_DATE - new Date(879897600*1000)) / 1000 / SEKUND_DOBY / 365.25).toFixed(1);
document.getElementById('pd-palil').textContent = latPalil + ' lat';
buildJubChart(jub.pct);
buildMilestoneList(d);
}
// ── Kurs CZK (NBP API, fallback na stały kurs) ───────────────────────────────
let CZK_RATE = 5.95; // fallback PLN→CZK
async function fetchCzkRate() {
try {
// NBP API: tabela kursów średnich, CZK
const r = await fetch('https://api.nbp.pl/api/exchangerates/rates/a/czk/?format=json');
if (!r.ok) throw new Error('NBP API error');
const json = await r.json();
const czk_per_pln_inv = json.rates[0].mid; // to jest ile PLN za 1 CZK
CZK_RATE = 1 / czk_per_pln_inv; // odwracamy: ile CZK za 1 PLN
const date = json.rates[0].effectiveDate;
document.getElementById('sd-czk-rate').textContent =
`kurs z ${date}: 1 PLN = ${CZK_RATE.toFixed(3)} CZK`;
} catch {
document.getElementById('sd-czk-rate').textContent =
`kurs przybliżony: 1 PLN ≈ ${CZK_RATE.toFixed(2)} CZK`;
}
}
// ── Init
update();
buildSavingsChart();
fetchCzkRate();
setInterval(update, 1000);
</script>
</body>
</html>

View File

@ -0,0 +1,704 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Rzuć palenie 🚭</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚭</text></svg>">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f172a; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; font-size: 14px; min-height: 100vh; }
/* ── SETUP ── */
#setup-screen {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 24px; background: radial-gradient(ellipse at 60% 30%, #1e3a5f 0%, #0f172a 70%);
}
.setup-box {
background: #1e293b; border: 1px solid #334155; border-radius: 16px;
padding: 36px 40px; max-width: 520px; width: 100%;
box-shadow: 0 25px 60px rgba(0,0,0,.5);
}
.setup-hero { text-align: center; margin-bottom: 28px; }
.setup-hero .icon { font-size: 52px; margin-bottom: 10px; }
.setup-hero h1 { font-size: 22px; font-weight: 700; color: #f1f5f9; margin-bottom: 8px; }
.setup-hero p { color: #64748b; font-size: 13px; line-height: 1.6; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 11px; font-weight: 700; color: #94a3b8;
text-transform: uppercase; letter-spacing: .06em; margin-bottom: 6px; }
.form-group input {
width: 100%; background: #0f172a; border: 1px solid #334155; border-radius: 8px;
padding: 10px 13px; color: #f1f5f9; font-size: 15px; outline: none; transition: border-color .2s;
}
.form-group input:focus { border-color: #34d399; }
.form-group input::placeholder { color: #475569; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.btn-primary {
width: 100%; padding: 13px; background: linear-gradient(135deg, #059669, #34d399);
border: none; border-radius: 10px; color: #fff; font-size: 15px; font-weight: 700;
cursor: pointer; transition: opacity .2s, transform .1s; margin-top: 6px;
}
.btn-primary:hover { opacity: .9; transform: translateY(-1px); }
.btn-today {
width: 100%; padding: 8px; background: transparent; border: 1px solid #334155;
border-radius: 8px; color: #64748b; font-size: 12px; cursor: pointer;
transition: all .2s; margin-bottom: 6px;
}
.btn-today:hover { border-color: #34d399; color: #34d399; }
.error-box { background: #450a0a; border: 1px solid #f87171; border-radius: 8px;
padding: 10px 14px; color: #fca5a5; font-size: 13px; margin-bottom: 16px; }
.share-box {
background: #0f172a; border: 1px solid #334155; border-radius: 10px;
padding: 14px; margin-top: 20px; text-align: center;
}
.share-box p { font-size: 12px; color: #64748b; margin-bottom: 8px; }
.share-url { font-size: 12px; color: #34d399; word-break: break-all;
background: #0f2027; padding: 8px 10px; border-radius: 6px; }
/* ── DASHBOARD ── */
#dashboard-screen { display: none; }
header {
padding: 12px 20px; background: #1e293b; border-bottom: 1px solid #334155;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
header h1 { font-size: 15px; font-weight: 600; color: #f1f5f9; }
.btn-icon { background: none; border: none; color: #64748b; cursor: pointer;
font-size: 18px; padding: 4px 8px; border-radius: 6px; transition: color .2s, background .2s; }
.btn-icon:hover { color: #e2e8f0; background: #334155; }
main {
display: grid; grid-template-columns: repeat(12, 1fr);
gap: 14px; padding: 16px 20px; max-width: 1440px; margin: 0 auto;
}
.col-3 { grid-column: span 3; }
.col-4 { grid-column: span 4; }
.col-5 { grid-column: span 5; }
.col-6 { grid-column: span 6; }
.col-7 { grid-column: span 7; }
.col-12 { grid-column: span 12; }
@media (max-width: 1100px) {
.col-3,.col-4,.col-5,.col-6,.col-7 { grid-column: span 12; }
}
.card { background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 16px; }
.card-title { font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: #64748b; margin-bottom: 12px; }
/* HERO */
.hero {
background: linear-gradient(135deg, #0d2137 0%, #0f2027 50%, #0d2137 100%);
border: 1px solid #334155; border-radius: 12px; padding: 28px 24px; text-align: center;
position: relative; overflow: hidden;
}
.hero::before { content:'🚭'; position:absolute; font-size:180px; opacity:.03;
top:50%; left:50%; transform:translate(-50%,-50%); pointer-events:none; }
.hero-greeting { font-size:13px; color:#64748b; margin-bottom:4px; }
.hero-greeting strong { color:#34d399; }
.hero-subtitle { font-size:11px; color:#475569; margin-bottom:20px; }
.counter-row { display:flex; justify-content:center; gap:12px; flex-wrap:wrap; }
.counter-unit { background:rgba(15,23,42,.75); border:1px solid #334155; border-radius:10px;
padding:14px 18px; min-width:80px; text-align:center; }
.counter-val { font-size:34px; font-weight:700; color:#f1f5f9; line-height:1; font-variant-numeric:tabular-nums; }
.counter-lbl { font-size:10px; color:#64748b; margin-top:4px; text-transform:uppercase; letter-spacing:.06em; }
/* STATS */
.stat-big { text-align:center; }
.stat-big .val { font-size:30px; font-weight:700; line-height:1.1; }
.stat-big .lbl { font-size:11px; color:#64748b; margin-top:5px; }
.stat-big .sub { font-size:11px; color:#475569; margin-top:3px; }
.green { color:#34d399; } .amber { color:#fbbf24; } .red { color:#f87171; } .violet { color:#a78bfa; }
/* HEALTH */
.milestone-scroll { max-height:380px; overflow-y:auto; scrollbar-width:thin; scrollbar-color:#334155 transparent; }
.ms-item { display:flex; gap:12px; padding:10px 8px; border-radius:8px; margin-bottom:2px; transition:background .2s; }
.ms-item:hover { background:rgba(255,255,255,.03); }
.ms-left { display:flex; flex-direction:column; align-items:center; width:24px; flex-shrink:0; }
.ms-dot { width:12px; height:12px; border-radius:50%; margin-top:3px; flex-shrink:0; }
.ms-dot.done { background:#34d399; box-shadow:0 0 6px rgba(52,211,153,.5); }
.ms-dot.active { background:#fbbf24; box-shadow:0 0 8px rgba(251,191,36,.6); animation:pulse 2s infinite; }
.ms-dot.future { background:#1e3a5f; border:1px solid #334155; }
.ms-line { flex:1; width:2px; background:#1e3a5f; margin-top:4px; }
.ms-item:last-child .ms-line { display:none; }
.ms-time { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; margin-bottom:2px; }
.ms-time.done{color:#34d399;} .ms-time.active{color:#fbbf24;} .ms-time.future{color:#475569;}
.ms-desc { font-size:12px; line-height:1.5; }
.ms-desc.done{color:#94a3b8;} .ms-desc.active{color:#e2e8f0;font-weight:500;} .ms-desc.future{color:#475569;}
.ms-prog { margin-top:5px; display:flex; align-items:center; gap:8px; }
.ms-prog-bg { flex:1; background:#0f172a; border-radius:3px; height:4px; overflow:hidden; }
.ms-prog-fill { height:100%; border-radius:3px; background:#fbbf24; transition:width .5s; }
.ms-prog-label { font-size:10px; color:#64748b; white-space:nowrap; }
@keyframes pulse { 0%,100%{box-shadow:0 0 6px rgba(251,191,36,.4);}50%{box-shadow:0 0 14px rgba(251,191,36,.8);} }
/* CHART */
.chart-wrap { height:200px; position:relative; }
/* EQUIVALENTS */
.equiv-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(110px,1fr)); gap:8px; }
.equiv-card { background:#0f172a; border-radius:8px; padding:10px 8px; text-align:center; border:1px solid #1e3a5f; }
.equiv-icon { font-size:26px; margin-bottom:5px; }
.equiv-count { font-size:18px; font-weight:700; color:#f1f5f9; }
.equiv-label { font-size:10px; color:#64748b; margin-top:2px; }
/* PROJECTION */
.progress-bar-row { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
.progress-bar-row .pb-label { font-size:11px; color:#94a3b8; width:70px; flex-shrink:0; }
.pb-bg { flex:1; background:#0f172a; border-radius:4px; height:8px; overflow:hidden; }
.pb-fill { height:100%; border-radius:4px; transition:width .6s; }
.pb-fill.green { background:linear-gradient(90deg,#059669,#34d399); }
.pb-fill.amber { background:linear-gradient(90deg,#b45309,#fbbf24); }
.pb-val { font-size:11px; color:#64748b; width:64px; text-align:right; white-space:nowrap; }
/* EMERGENCY */
.btn-emergency {
background:linear-gradient(135deg,#7f1d1d,#ef4444); border:none; border-radius:12px;
color:#fff; font-size:15px; font-weight:700; padding:14px 28px;
cursor:pointer; transition:transform .15s,opacity .15s; box-shadow:0 4px 20px rgba(239,68,68,.3);
}
.btn-emergency:hover { opacity:.9; transform:scale(1.02); }
/* MODALS */
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.7); z-index:1000;
align-items:center; justify-content:center; padding:20px; backdrop-filter:blur(4px); }
.modal-overlay.open { display:flex; }
.modal { background:#1e293b; border:1px solid #334155; border-radius:16px; padding:28px 32px;
max-width:440px; width:100%; box-shadow:0 25px 60px rgba(0,0,0,.6);
animation:slideUp .2s ease; }
@keyframes slideUp { from{opacity:0;transform:translateY(16px)} to{opacity:1;transform:translateY(0)} }
.modal .big-icon { font-size:48px; text-align:center; margin-bottom:14px; }
.modal h2 { font-size:19px; font-weight:700; color:#f1f5f9; margin-bottom:10px; text-align:center; }
.modal p { color:#94a3b8; line-height:1.7; font-size:13px; margin-bottom:16px; text-align:center; }
.stats-remind { background:#0f172a; border-radius:10px; padding:13px; margin-bottom:16px; }
.stats-remind div { font-size:13px; color:#e2e8f0; margin-bottom:6px; }
.stats-remind div:last-child { margin-bottom:0; }
.stats-remind strong { color:#34d399; }
.btn-close { background:#334155; border:none; border-radius:8px; color:#e2e8f0;
font-size:14px; font-weight:600; padding:10px 22px; cursor:pointer; transition:background .2s; }
.btn-close:hover { background:#475569; }
.modal-quote { font-style:italic; color:#64748b; font-size:12px; margin-top:14px; text-align:center; }
/* SETTINGS */
#settings-modal .modal { max-width:480px; }
#settings-modal .modal h2 { text-align:left; }
.url-copy { display:flex; gap:8px; align-items:center; margin-top:14px; }
.url-copy input { flex:1; background:#0f172a; border:1px solid #334155; border-radius:7px;
padding:8px 11px; color:#34d399; font-size:12px; outline:none; }
.url-copy button { background:#334155; border:none; border-radius:7px; color:#e2e8f0;
font-size:12px; padding:8px 13px; cursor:pointer; white-space:nowrap; }
.url-copy button:hover { background:#475569; }
</style>
</head>
<body>
{% if not config %}
<!-- ══════════════════════════════════════════════════════════ SETUP ══ -->
<div id="setup-screen">
<div class="setup-box">
<div class="setup-hero">
<div class="icon">🚭</div>
<h1>Zaplanuj rzucenie palenia</h1>
<p>Podaj swoje dane - dostaniesz swój prywatny link do śledzenia postępów i motywacji.</p>
</div>
{% if error %}
<div class="error-box">{{ error }}</div>
{% endif %}
<form method="post" action="/rzucanie">
<div class="form-group">
<label>Twoje imię (opcjonalnie)</label>
<input type="text" name="name" placeholder="np. Mama" autocomplete="off"/>
</div>
<div class="form-row">
<div class="form-group">
<label>Papierosów dziennie</label>
<input type="number" name="dziennie" value="15" min="1" max="80" required/>
</div>
<div class="form-group">
<label>Sztuk w paczce</label>
<input type="number" name="paczka" value="20" min="10" max="30" required/>
</div>
</div>
<div class="form-group">
<label>Cena paczki (zł)</label>
<input type="number" name="cena" value="22.00" min="1" step="0.50" required/>
</div>
<div class="form-group">
<label>Data i godzina rzucenia</label>
<button type="button" class="btn-today" onclick="setToday()">📅 Rzuciłam/em dzisiaj - ustaw teraz</button>
<input type="datetime-local" name="rzucone" id="f-rzucone" required/>
</div>
<button type="submit" class="btn-primary">🚀 Zaczynam! Stwórz mój prywatny link</button>
</form>
<div class="share-box">
<p>Po kliknięciu otrzymasz unikalny link - zachowaj go i udostępnij znajomym.</p>
</div>
</div>
</div>
<script>
function setToday() {
const now = new Date();
document.getElementById('f-rzucone').value =
new Date(now - now.getTimezoneOffset() * 60000).toISOString().slice(0,16);
}
// Domyślnie ustaw teraz
(function() {
const now = new Date();
document.getElementById('f-rzucone').value =
new Date(now - now.getTimezoneOffset() * 60000).toISOString().slice(0,16);
})();
</script>
{% else %}
<!-- ═════════════════════════════════════════════════════ DASHBOARD ══ -->
<script>
const CONFIG = {{ config | tojson }};
const TOKEN = {{ token | tojson }};
const PAGE_URL = window.location.href;
</script>
<div id="dashboard-screen" style="display:block">
<header>
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:20px">🚭</span>
<h1 id="header-title">Nie palę!</h1>
</div>
<div style="display:flex;gap:6px;align-items:center">
<span id="header-since" style="font-size:11px;color:#475569"></span>
<button class="btn-icon" title="Ustawienia i link" onclick="openSettings()">⚙️</button>
</div>
</header>
<main>
<div class="hero col-12">
<div class="hero-greeting">Hej, <strong id="d-name"></strong> Nie palisz od:</div>
<div class="hero-subtitle" id="d-since-full"></div>
<div class="counter-row">
<div class="counter-unit"><div class="counter-val" id="c-lat">0</div><div class="counter-lbl">lat</div></div>
<div class="counter-unit"><div class="counter-val" id="c-dni">0</div><div class="counter-lbl">dni</div></div>
<div class="counter-unit"><div class="counter-val" id="c-godz">00</div><div class="counter-lbl">godzin</div></div>
<div class="counter-unit"><div class="counter-val" id="c-min">00</div><div class="counter-lbl">minut</div></div>
<div class="counter-unit"><div class="counter-val" id="c-sek">00</div><div class="counter-lbl">sekund</div></div>
</div>
</div>
<div class="card col-3">
<div class="card-title">Zaoszczędziłam/em</div>
<div class="stat-big">
<div class="val amber" id="d-zaoszcz"></div>
<div class="lbl">złotych</div>
<div class="sub" id="d-zaoszcz-sub"></div>
</div>
</div>
<div class="card col-3">
<div class="card-title">Nie wypaliłam/em</div>
<div class="stat-big">
<div class="val green" id="d-nie-wypalil"></div>
<div class="lbl">papierosów</div>
<div class="sub" id="d-nie-wypalil-sub"></div>
</div>
</div>
<div class="card col-3">
<div class="card-title">Następny krok zdrowotny</div>
<div class="stat-big">
<div class="val violet" id="d-next-ms-time"></div>
<div class="lbl" id="d-next-ms-desc" style="font-size:12px;color:#94a3b8;margin-top:8px;line-height:1.5"></div>
</div>
</div>
<div class="card col-3">
<div class="card-title">Prognoza oszczędności</div>
<div style="display:flex;flex-direction:column;gap:7px;margin-top:4px">
<div class="progress-bar-row">
<div class="pb-label">1 rok:</div>
<div class="pb-bg"><div class="pb-fill amber" id="pb-rok" style="width:0%"></div></div>
<div class="pb-val" id="pb-rok-val"></div>
</div>
<div class="progress-bar-row">
<div class="pb-label">2 lata:</div>
<div class="pb-bg"><div class="pb-fill green" id="pb-2lat" style="width:0%"></div></div>
<div class="pb-val" id="pb-2lat-val"></div>
</div>
<div class="progress-bar-row">
<div class="pb-label">5 lat:</div>
<div class="pb-bg"><div class="pb-fill amber" id="pb-5lat" style="width:0%"></div></div>
<div class="pb-val" id="pb-5lat-val"></div>
</div>
</div>
</div>
<div class="card col-5">
<div class="card-title">Co dzieje się z Twoim ciałem 🫁</div>
<div class="milestone-scroll" id="health-milestones"></div>
</div>
<div class="card col-7">
<div class="card-title">Oszczędności w czasie (zł)</div>
<div class="chart-wrap"><canvas id="savingsChart"></canvas></div>
</div>
<div class="card col-6">
<div class="card-title">Za te pieniądze mogłaś/mogłeś kupić... 💸</div>
<div class="equiv-grid" id="equiv-grid"></div>
</div>
<div class="card col-6" style="display:flex;flex-direction:column;justify-content:center">
<div class="card-title">Trudny moment?</div>
<div style="text-align:center">
<div style="color:#94a3b8;font-size:13px;margin-bottom:16px;line-height:1.6">
Masz ochotę zapalić? Kliknij tutaj<br>zanim sięgniesz po papierosa.
</div>
<button class="btn-emergency" onclick="openEmergency()">🆘 Mam ochotę na papierosa!</button>
<div style="margin-top:10px;font-size:11px;color:#475569">
Ochota na papierosa trwa zwykle 3-5 minut i mija sama.
</div>
</div>
</div>
</main>
</div>
<!-- EMERGENCY MODAL -->
<div class="modal-overlay" id="emergency-modal">
<div class="modal">
<div class="big-icon">💪</div>
<h2>Dasz radę! Naprawdę.</h2>
<p>Zaczekaj tylko 5 minut. Ta ochota minie - zawsze mija.</p>
<div class="stats-remind">
<div>⏱ Nie palisz już przez <strong id="enc-czas"></strong></div>
<div>💰 Zaoszczędziłaś/eś już <strong id="enc-kasa"></strong></div>
<div>🚬 Nie wypaliłaś/eś <strong id="enc-pap"></strong> papierosów</div>
</div>
<div style="text-align:center">
<button class="btn-close" onclick="closeModal('emergency-modal')">💚 Poradziłam/em sobie!</button>
</div>
<div class="modal-quote" id="enc-quote"></div>
</div>
</div>
<!-- SETTINGS MODAL -->
<div class="modal-overlay" id="settings-modal">
<div class="modal" id="settings-modal">
<h2>⚙️ Ustawienia</h2>
<div style="margin-bottom:16px">
<div class="form-group" style="margin-top:14px">
<label>Twój prywatny link</label>
<div class="url-copy">
<input type="text" id="share-url" readonly/>
<button onclick="copyUrl()">📋 Kopiuj</button>
</div>
<div style="font-size:11px;color:#475569;margin-top:6px">Zachowaj ten link - to Twój dostęp do strony.</div>
</div>
</div>
<div class="form-group">
<label>Imię</label>
<input type="text" id="s-name" placeholder="opcjonalnie"/>
</div>
<div class="form-row">
<div class="form-group">
<label>Papierosów dziennie</label>
<input type="number" id="s-dziennie" min="1" max="80"/>
</div>
<div class="form-group">
<label>Sztuk w paczce</label>
<input type="number" id="s-paczka" min="10" max="30"/>
</div>
</div>
<div class="form-group">
<label>Cena paczki (zł)</label>
<input type="number" id="s-cena" min="1" step="0.5"/>
</div>
<div class="form-group">
<label>Data rzucenia</label>
<input type="datetime-local" id="s-rzucone"/>
</div>
<div id="s-msg" style="font-size:12px;color:#34d399;min-height:18px;margin-bottom:8px"></div>
<div style="display:flex;gap:10px">
<button class="btn-primary" style="margin-top:0;flex:1" onclick="saveSettings()">Zapisz zmiany</button>
<button class="btn-close" style="flex:1" onclick="closeModal('settings-modal')">Zamknij</button>
</div>
</div>
</div>
<script>
// ── Constants ─────────────────────────────────────────────────────────────────
const HEALTH = [
{ s: 20*60, label:"20 minut", desc:"Ciśnienie krwi i tętno wracają do normy." },
{ s: 8*3600, label:"8 godzin", desc:"Poziom tlenku węgla we krwi spada o połowę. Więcej tlenu dla komórek." },
{ s: 24*3600, label:"24 godziny", desc:"Ryzyko zawału serca zaczyna spadać. Nikotyna opuszcza krew." },
{ s: 48*3600, label:"48 godzin", desc:"Zakończenia nerwowe się regenerują. Zapachy i smaki wracają!" },
{ s: 72*3600, label:"72 godziny", desc:"Nikotyna znika z organizmu. Oddychanie łatwiejsze, oskrzela się rozluźniają." },
{ s: 14*86400, label:"2 tygodnie", desc:"Krążenie znacznie się poprawia. Ćwiczenia są łatwiejsze." },
{ s: 30*86400, label:"1 miesiąc", desc:"Kaszel i duszności wyraźnie zmniejszają się. Skóra wygląda lepiej." },
{ s: 90*86400, label:"3 miesiące", desc:"Funkcja płuc poprawia się nawet o 30%. Mniej infekcji dróg oddechowych." },
{ s: 180*86400, label:"6 miesięcy", desc:"Zmęczenie i kaszel prawie znikają. Układ odpornościowy silniejszy." },
{ s: 365*86400, label:"1 rok", desc:"Ryzyko choroby wieńcowej spada o połowę w porównaniu do palacza!" },
{ s: 5*365*86400, label:"5 lat", desc:"Ryzyko udaru mózgu takie samo jak u osoby niepalącej." },
{ s: 10*365*86400, label:"10 lat", desc:"Ryzyko raka płuc zmniejsza się o połowę." },
{ s: 15*365*86400, label:"15 lat", desc:"Ryzyko chorób serca jak u osoby, która nigdy nie paliła. 🎉" },
];
const EQUIV = [
{ icon:"☕", name:"kawy", price:14 },
{ icon:"🍕", name:"pizze", price:40 },
{ icon:"💅", name:"manicure", price:60 },
{ icon:"🎬", name:"kina", price:35 },
{ icon:"📚", name:"książki", price:45 },
{ icon:"🌺", name:"bukiety", price:50 },
{ icon:"🍷", name:"wina", price:35 },
{ icon:"👟", name:"pary butów", price:200},
{ icon:"🛀", name:"wizyty SPA", price:180},
{ icon:"✈️", name:"loty tanie", price:150},
{ icon:"🎭", name:"teatr", price:80 },
{ icon:"🍰", name:"torty", price:80 },
];
const QUOTES = [
"„Każda chwila bez papierosa to chwila, w której Twoje ciało się leczy."",
"„Najtrudniejszy krok to pierwszy. Już go zrobiłaś."",
"„Twoje płuca dziękują Ci z każdym oddechem."",
"„To nie słabość czuć pokusę - siłą jest nie ulec."",
"„Za 5 minut ta ochota minie. Zawsze mija."",
"„Jesteś silniejsza niż myślisz."",
"„Pamiętasz dlaczego zaczęłaś? Ten powód jest wciąż aktualny."",
];
// ── Helpers ───────────────────────────────────────────────────────────────────
const RZUCONE_DATE = new Date(CONFIG.rzucone);
const KOSZT_DZIENNY = (CONFIG.dziennie / CONFIG.paczka) * CONFIG.cena;
const KOSZT_NA_SEK = KOSZT_DZIENNY / 86400;
function fmtPln(n) { return n.toLocaleString('pl-PL',{minimumFractionDigits:2,maximumFractionDigits:2})+' zł'; }
function fmtInt(n) { return Math.floor(n).toLocaleString('pl-PL'); }
function decompose(s) {
const lat = Math.floor(s / (365.25*86400));
let rem = s % (365.25*86400);
const dni = Math.floor(rem / 86400); rem = rem % 86400;
const godz = Math.floor(rem / 3600); rem = rem % 3600;
const min = Math.floor(rem / 60);
const sek = Math.floor(rem % 60);
return { lat, dni, godz, min, sek };
}
function fmtDur(s) {
if (s < 3600) return `${Math.floor(s/60)} min`;
if (s < 86400) return `${Math.floor(s/3600)} godz`;
if (s < 86400*30) return `${Math.floor(s/86400)} dni`;
if (s < 86400*365) return `${Math.floor(s/86400/30)} mies`;
return `${(s/86400/365.25).toFixed(1)} lat`;
}
// ── Update loop ───────────────────────────────────────────────────────────────
function update() {
const totalS = Math.max(0, (new Date() - RZUCONE_DATE) / 1000);
const dc = decompose(totalS);
document.getElementById('c-lat').textContent = dc.lat;
document.getElementById('c-dni').textContent = dc.dni;
document.getElementById('c-godz').textContent = String(dc.godz).padStart(2,'0');
document.getElementById('c-min').textContent = String(dc.min).padStart(2,'0');
document.getElementById('c-sek').textContent = String(dc.sek).padStart(2,'0');
const zaoszcz = totalS * KOSZT_NA_SEK;
const nieWypalil = Math.floor(CONFIG.dziennie * (totalS / 86400));
document.getElementById('d-zaoszcz').textContent = fmtPln(zaoszcz);
document.getElementById('d-zaoszcz-sub').textContent = fmtPln(KOSZT_DZIENNY*7)+'/tydzień';
document.getElementById('d-nie-wypalil').textContent = fmtInt(nieWypalil);
document.getElementById('d-nie-wypalil-sub').textContent = fmtInt(nieWypalil/CONFIG.paczka)+' paczek';
const rok=KOSZT_DZIENNY*365, lat2=KOSZT_DZIENNY*730, lat5=KOSZT_DZIENNY*1825;
const pct=v=>Math.min(100,zaoszcz/v*100).toFixed(1);
document.getElementById('pb-rok').style.width = pct(rok)+'%';
document.getElementById('pb-2lat').style.width = pct(lat2)+'%';
document.getElementById('pb-5lat').style.width = pct(lat5)+'%';
document.getElementById('pb-rok-val').textContent = fmtInt(rok)+' zł';
document.getElementById('pb-2lat-val').textContent = fmtInt(lat2)+' zł';
document.getElementById('pb-5lat-val').textContent = fmtInt(lat5)+' zł';
updateHealthMilestones(totalS);
updateEquiv(zaoszcz);
}
// ── Health milestones ─────────────────────────────────────────────────────────
(function buildHealth() {
const el = document.getElementById('health-milestones');
el.innerHTML = HEALTH.map((ms,i) => `
<div class="ms-item">
<div class="ms-left">
<div class="ms-dot future" id="msd-${i}"></div>
<div class="ms-line"></div>
</div>
<div style="flex:1">
<div class="ms-time future" id="mst-${i}">${ms.label}</div>
<div class="ms-desc future" id="msb-${i}">${ms.desc}</div>
<div class="ms-prog" id="msp-${i}" style="display:none">
<div class="ms-prog-bg"><div class="ms-prog-fill" id="mspf-${i}" style="width:0%"></div></div>
<div class="ms-prog-label" id="mspl-${i}"></div>
</div>
</div>
</div>`).join('');
})();
function updateHealthMilestones(totalS) {
let nextSet=false, nextTime='', nextDesc='', prev=0;
HEALTH.forEach((ms,i)=>{
const dot=document.getElementById(`msd-${i}`);
const time=document.getElementById(`mst-${i}`);
const body=document.getElementById(`msb-${i}`);
const prog=document.getElementById(`msp-${i}`);
if (!dot) return;
if (totalS >= ms.s) {
dot.className='ms-dot done'; time.className='ms-time done'; body.className='ms-desc done';
prog.style.display='none';
} else if (!nextSet) {
nextSet=true;
const pct=((totalS-prev)/(ms.s-prev)*100).toFixed(1);
dot.className='ms-dot active'; time.className='ms-time active'; body.className='ms-desc active';
prog.style.display='flex';
document.getElementById(`mspf-${i}`).style.width=pct+'%';
document.getElementById(`mspl-${i}`).textContent='za '+fmtDur(ms.s-totalS);
nextTime='za '+fmtDur(ms.s-totalS); nextDesc=ms.desc;
} else {
dot.className='ms-dot future'; time.className='ms-time future'; body.className='ms-desc future';
prog.style.display='none';
}
prev=ms.s;
});
document.getElementById('d-next-ms-time').textContent=nextTime||'✅ Wszystkie!';
document.getElementById('d-next-ms-desc').textContent=nextDesc;
}
// ── Equivalents ───────────────────────────────────────────────────────────────
(function buildEquiv() {
document.getElementById('equiv-grid').innerHTML =
EQUIV.map((e,i)=>`
<div class="equiv-card">
<div class="equiv-icon">${e.icon}</div>
<div class="equiv-count" id="eq-${i}">0</div>
<div class="equiv-label">${e.name}</div>
</div>`).join('');
})();
function updateEquiv(zaoszcz) {
EQUIV.forEach((e,i)=>{
const el=document.getElementById(`eq-${i}`);
if(el) el.textContent=Math.floor(zaoszcz/e.price);
});
}
// ── Savings chart ─────────────────────────────────────────────────────────────
(function buildChart() {
const ctx=document.getElementById('savingsChart').getContext('2d');
const labels=[], vals=[];
let d=new Date(RZUCONE_DATE); d.setDate(1);
const now=new Date();
while (d<=now) {
labels.push(d.toLocaleDateString('pl-PL',{year:'2-digit',month:'short'}));
vals.push(parseFloat(((d-RZUCONE_DATE)/1000*KOSZT_NA_SEK).toFixed(2)));
d.setMonth(d.getMonth()+1);
}
new Chart(ctx,{
type:'line',
data:{ labels, datasets:[{ data:vals, borderColor:'#34d399', backgroundColor:'rgba(52,211,153,.1)',
borderWidth:2, pointRadius:0, tension:0.3, fill:true }] },
options:{
responsive:true, maintainAspectRatio:false,
plugins:{ legend:{display:false}, tooltip:{ mode:'index', intersect:false,
callbacks:{ label:c=>' '+c.raw.toLocaleString('pl-PL',{minimumFractionDigits:2})+' zł' } } },
scales:{
x:{ ticks:{color:'#475569',maxRotation:0,maxTicksLimit:10}, grid:{color:'#1e293b'} },
y:{ ticks:{color:'#475569',callback:v=>v.toLocaleString('pl-PL')+' zł'}, grid:{color:'#1e293b'} }
}
}
});
})();
// ── Emergency ─────────────────────────────────────────────────────────────────
function openEmergency() {
const totalS=Math.max(0,(new Date()-RZUCONE_DATE)/1000);
const dc=decompose(totalS);
let t='';
if(dc.lat>0) t=`${dc.lat} lat, ${dc.dni} dni`;
else if(dc.dni>0) t=`${dc.dni} dni i ${dc.godz} godz`;
else if(dc.godz>0) t=`${dc.godz} godz i ${dc.min} min`;
else t=`${dc.min} minut`;
document.getElementById('enc-czas').textContent=t;
document.getElementById('enc-kasa').textContent=fmtPln(totalS*KOSZT_NA_SEK);
document.getElementById('enc-pap').textContent=fmtInt(CONFIG.dziennie*(totalS/86400));
document.getElementById('enc-quote').textContent=QUOTES[Math.floor(Math.random()*QUOTES.length)];
document.getElementById('emergency-modal').classList.add('open');
}
// ── Settings ──────────────────────────────────────────────────────────────────
function openSettings() {
document.getElementById('s-name').value = CONFIG.name||'';
document.getElementById('s-dziennie').value = CONFIG.dziennie;
document.getElementById('s-paczka').value = CONFIG.paczka;
document.getElementById('s-cena').value = CONFIG.cena;
document.getElementById('s-rzucone').value = CONFIG.rzucone;
document.getElementById('share-url').value = PAGE_URL;
document.getElementById('s-msg').textContent='';
document.getElementById('settings-modal').classList.add('open');
}
function copyUrl() {
navigator.clipboard.writeText(PAGE_URL).then(()=>{
const btn=document.querySelector('#settings-modal .url-copy button');
btn.textContent='✅ Skopiowano!';
setTimeout(()=>btn.textContent='📋 Kopiuj',2000);
});
}
async function saveSettings() {
const payload = {
name: document.getElementById('s-name').value.trim(),
dziennie:parseFloat(document.getElementById('s-dziennie').value),
paczka: parseFloat(document.getElementById('s-paczka').value),
cena: parseFloat(document.getElementById('s-cena').value),
rzucone: document.getElementById('s-rzucone').value,
};
const r = await fetch(`/api/rzucanie/${TOKEN}`, {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
});
if (r.ok) {
document.getElementById('s-msg').textContent='✅ Zapisano!';
Object.assign(CONFIG, payload);
setTimeout(()=>location.reload(), 800);
} else {
document.getElementById('s-msg').textContent='❌ Błąd zapisu.';
document.getElementById('s-msg').style.color='#f87171';
}
}
// ── Modals ────────────────────────────────────────────────────────────────────
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
document.querySelectorAll('.modal-overlay').forEach(el=>{
el.addEventListener('click',e=>{ if(e.target===el) el.classList.remove('open'); });
});
// ── Init ──────────────────────────────────────────────────────────────────────
(function init() {
const imie = CONFIG.name || '';
document.getElementById('d-name').textContent = imie ? imie+'!' : '';
document.getElementById('header-title').textContent = imie ? `🚭 ${imie} - nie palisz!` : '🚭 Nie palę!';
const opts={weekday:'long',year:'numeric',month:'long',day:'numeric',hour:'2-digit',minute:'2-digit'};
document.getElementById('d-since-full').textContent = RZUCONE_DATE.toLocaleString('pl-PL',opts);
document.getElementById('header-since').textContent = 'od '+RZUCONE_DATE.toLocaleDateString('pl-PL');
update();
setInterval(update, 1000);
})();
</script>
{% endif %}
</body>
</html>

View File

@ -155,8 +155,11 @@ def check_calendar_override(now_dt):
# Parsuj czas zakonczenia
end_str = ev.get("end", "")
if "T" in end_str:
# Timed event: "2026-04-04T09:00:00"
# Timed event: "2026-04-04T09:00:00" or "2026-04-04T09:00:00+02:00"
end_dt = datetime.fromisoformat(end_str)
# Google Calendar moze zwracac czas z timezone - ujednolicamy do naive local
if end_dt.tzinfo is not None:
end_dt = end_dt.astimezone().replace(tzinfo=None)
else:
# All-day: "2026-04-05" oznacza koniec o 00:00 nastepnego dnia
# -> ustaw na 23:59:59 dnia poprzedniego (ostatni moment dnia eventu)

View File

@ -1,13 +1,13 @@
{
"ha_log_pos": 4554206,
"ha_history_last_ts": 1775331867.057616,
"ha_log_pos": 5032700,
"ha_history_last_ts": 1775723911.899412,
"frigate_last_id": "1774114966.094129-j8nxv6",
"docker_since_traefik": 1775301395,
"docker_since_homeassistant": 1775332084,
"docker_since_immich_server": 1775305360,
"docker_since_frigate": 1775332093,
"docker_since_ha-dashboard": 1775332095,
"docker_since_esphome": 1775332092,
"docker_since_mosquitto": 1774010092,
"docker_since_crowdsec": 1775332092
"docker_since_traefik": 1775724569,
"docker_since_homeassistant": 1775728496,
"docker_since_immich_server": 1775723966,
"docker_since_frigate": 1775459569,
"docker_since_ha-dashboard": 1775461944,
"docker_since_esphome": 1775728496,
"docker_since_mosquitto": 1775461949,
"docker_since_crowdsec": 1775461949
}

View File

@ -5,7 +5,7 @@ description: "1337x is a Public torrent site that offers verified torrent downlo
language: en-US
type: public
encoding: UTF-8
requestDelay: 2
requestDelay: 3
# get status and news on domains at the official site https://1337x-status.org/
links:
- https://1337x.to/

View File

@ -13,6 +13,7 @@ caps:
- {id: 1, cat: TV, desc: "Live Concert"}
- {id: 2, cat: Audio/Video, desc: "Music Video"}
- {id: 4, cat: TV, desc: "Music Documentary"}
- {id: 11, cat: TV, desc: "Music TV Series"}
- {id: 5, cat: Audio/Lossless, desc: "Pure Audio"}
- {id: 7, cat: TV, desc: "Opera"}
- {id: 8, cat: TV, desc: "Musical"}

View File

@ -155,6 +155,6 @@ search:
minimumratio:
text: 1.4
minimumseedtime:
# 7 days (as seconds = 7 x 24 x 60 x 60)
text: 604800
# 5 days (as seconds = 5 x 24 x 60 x 60)
text: 432000
# xbtitFM v4.1.32

View File

@ -144,43 +144,43 @@ search:
attribute: src
case:
# not all categories are available at the moment, so some are deduced and unconfirmed
img[src$="/anime.png"]: 1
img[src$="/anime-ro.png"]: 2
img[src$="/bluray.png"]: 5
img[src$="/bluray-ro.png"]: 6
img[src$="/dvd.png"]: 9
img[src$="/dvd-ro.png"]: 10
img[src$="/hd.png"]: 11
img[src$="/hd-ro.png"]: 12
img[src$="/hdtve.png"]: 13
img[src$="/hdtve-ro.png"]: 14
img[src$="/oldies.png"]: 16
img[src$="/oldies-ro.png"]: 17
img[src$="/pack.png"]: 18
img[src$="/pack-ro.png"]: 19
img[src$="/tvepisode.png"]: 20
img[src$="/tvepisode-ro.png"]: 21
img[src$="/rocontent.png"]: 22
img[src$="/xvid.png"]: 24
img[src$="/xvid-ro.png"]: 25
img[src$="/soft.png"]: 26
img[src$="/xxx.jpeg"]: 27
img[src$="/music.png"]: 28
img[src$="/pciso.png"]: 30
img[src$="/4k.png"]: 31
img[src$="/4kRO.png"]: 32
img[src$="/gpack.png"]: 33
img[src$="/xxxpack.jpeg"]: 34
img[src$="/sport.png"]: 42
img[src$="/docs.png"]: 43
img[src$="/images.png"]: 44
img[src$="/misc.png"]: 49
img[src$="/web-DL.png"]: 54
img[src$="/web-DLRO.png"]: 55
img[src$="/doc.png"]: 56
img[src$="/doc-ro.png"]: 57
img[src$="/x265ro.png"]: 81
img[src$="/x265.png"]: 82
img[src*="/anime.png"]: 1
img[src*="/anime-ro.png"]: 2
img[src*="/bluray.png"]: 5
img[src*="/bluray-ro.png"]: 6
img[src*="/dvd.png"]: 9
img[src*="/dvd-ro.png"]: 10
img[src*="/hd.png"]: 11
img[src*="/hd-ro.png"]: 12
img[src*="/hdtve.png"]: 13
img[src*="/hdtve-ro.png"]: 14
img[src*="/oldies.png"]: 16
img[src*="/oldies-ro.png"]: 17
img[src*="/pack.png"]: 18
img[src*="/pack-ro.png"]: 19
img[src*="/tvepisode.png"]: 20
img[src*="/tvepisode-ro.png"]: 21
img[src*="/rocontent.png"]: 22
img[src*="/xvid.png"]: 24
img[src*="/xvid-ro.png"]: 25
img[src*="/soft.png"]: 26
img[src*="/xxx.jpeg"]: 27
img[src*="/music.png"]: 28
img[src*="/pciso.png"]: 30
img[src*="/4k.png"]: 31
img[src*="/4kRO.png"]: 32
img[src*="/gpack.png"]: 33
img[src*="/xxxpack.jpeg"]: 34
img[src*="/sport.png"]: 42
img[src*="/docs.png"]: 43
img[src*="/images.png"]: 44
img[src*="/misc.png"]: 49
img[src*="/web-DL.png"]: 54
img[src*="/web-DLRO.png"]: 55
img[src*="/doc.png"]: 56
img[src*="/doc-ro.png"]: 57
img[src*="/x265ro.png"]: 81
img[src*="/x265.png"]: 82
title:
selector: a[href*="/torrents/"]
details:

View File

@ -9,16 +9,12 @@ encoding: UTF-8
links:
- https://magnetcatcat.com/
- https://clmclm.com/
- https://www.8800552.xyz/
- https://www.8800554.xyz/
- https://www.8800556.xyz/
- https://www.8800557.xyz/
- https://www.8800558.xyz/
- https://www.8800559.xyz/
- https://www.8800560.xyz/
- https://www.8800561.xyz/
legacylinks:
- https://www.clm472.sbs/
- https://www.8800536.xyz/
- https://www.8800537.xyz/
- https://www.8800538.xyz/
- https://www.8800539.xyz/
- https://www.8800540.xyz/
- https://www.8800541.xyz/
- https://www.8800542.xyz/
@ -31,6 +27,10 @@ legacylinks:
- https://www.8800549.xyz/
- https://www.8800550.xyz/
- https://www.8800551.xyz/
- https://www.8800552.xyz/
- https://www.8800554.xyz/
- https://www.8800556.xyz/
- https://www.8800557.xyz/
caps:
categorymappings:

View File

@ -17,6 +17,7 @@ caps:
- {id: 3, cat: TV/Documentary, desc: "Documentaires"}
- {id: 4, cat: TV/Anime, desc: "Animés"}
- {id: 5, cat: TV/Other, desc: "Concerts / Spectacles"}
- {id: 7, cat: TV/Sport, desc: "Sports"}
modes:
search: [q]

View File

@ -10,7 +10,7 @@ type: public
encoding: UTF-8
# to fetch current domain use https://www.rantop.org/
links:
- https://www.nortorrent.org/
- https://www.nortorrent.cc/
legacylinks:
# latest domains list
- https://www.rantop.xyz/
@ -29,7 +29,6 @@ legacylinks:
- https://www.torrent-site.com/
- https://www.rantop.org/
# actual legacylinks
- https://www.nortorrent4.com/
- https://www.nortorrent5.com/
- https://www.nortorrent6.com/
- https://www.nortorrent.net/
@ -44,6 +43,7 @@ legacylinks:
- https://www-nortorrent.com/
- https://ww2-nortorrent.com/
- https://ww3-nortorrent.com/
- https://www.nortorrent.org/
caps:
categorymappings:

View File

@ -137,7 +137,7 @@ search:
by: "{{ .Config.type }}"
rows:
selector: table.lista[width="100%"] tr > td > table.lista[width="100%"] tr:has(a[href*="category="])
selector: table.torrent-index__table:not(table.torrent-index__table--recommended) > tbody > tr.torrent-index__row
fields:
category:

View File

@ -0,0 +1,139 @@
---
id: scenetime-api
name: SceneTime (API)
description: "SceneTime is a Private Torrent Tracker for MOVIES / TV / GENERAL"
language: en-US
type: private
encoding: UTF-8
links:
- https://www.scenetime.com/
caps:
categorymappings:
# from https://www.scenetime.com/rss.php?apikey=YOUR-API-KEY&t=caps
- {id: 1000, cat: Console, desc: "Console"}
- {id: 100015, cat: Console/Other, desc: "Games Dreamcast"}
- {id: 100055, cat: Console/NDS, desc: "Games Nintendo"}
- {id: 100012, cat: Console/PS4, desc: "Games PS"}
- {id: 100051, cat: Console/Wii, desc: "Games Wii"}
- {id: 100048, cat: Console/XBox, desc: "Games XBOX"}
- {id: 2000, cat: Movies, desc: "Movies"}
- {id: 100047, cat: Movies, desc: "Movie Packs"}
- {id: 100064, cat: Movies/3D, desc: "Movies 3D"}
- {id: 100082, cat: Movies/Other, desc: "Movies CAM/TS"}
- {id: 100059, cat: Movies/HD, desc: "Movies HD"}
- {id: 100057, cat: Movies/SD, desc: "Movies SD"}
- {id: 100016, cat: Movies/UHD, desc: "Movies UHD"}
- {id: 3000, cat: Audio, desc: "Audio"}
- {id: 100004, cat: Audio, desc: "Music"}
- {id: 100116, cat: Audio, desc: "Music Packs"}
- {id: 4000, cat: PC, desc: "PC"}
- {id: 100006, cat: PC/Games, desc: "Games PC-ISO"}
- {id: 100053, cat: PC/0day, desc: "Apps"}
- {id: 100052, cat: PC/Mac, desc: "Mac/Linux"}
- {id: 5000, cat: TV, desc: "TV"}
- {id: 100001, cat: TV/Anime, desc: "TV ANIME"}
- {id: 100009, cat: TV/HD, desc: "TV HD"}
- {id: 100043, cat: TV, desc: "TV Packs"}
- {id: 100077, cat: TV/SD, desc: "TV SD"}
- {id: 100002, cat: TV/UHD, desc: "TV UHD"}
- {id: 6000, cat: XXX, desc: "XXX"}
- {id: 100010, cat: XXX, desc: "Movies Adult"}
- {id: 7000, cat: Books, desc: "Books"}
- {id: 100007, cat: Books, desc: "Books and Magazines"}
- {id: 100065, cat: Books/Comics, desc: "Comic Books"}
- {id: 8000, cat: Other, desc: "Other"}
- {id: 100024, cat: PC/Mobile-Other, desc: "Mobile Apps"}
modes:
search: [q]
tv-search: [q, season, ep, imdbid]
movie-search: [q, imdbid]
music-search: [q]
book-search: [q]
settings:
- name: apikey
type: text
label: API Key
- name: info_key
type: info
label: About your API key
default: "You can find your API Key by accessing the <a href=\"https://scenetime.com/rss.php\" target=\"_blank\">SceneTime RSS</a> page, scrolling down to the <b>Torznab API</b> section, and copying the <b>API Key</b>."
- name: freeleech
type: checkbox
label: Search freeleech only
default: false
- name: info_activity
type: info
label: Account Inactivity
default: "Unused accounts (accounts with no activity) and inactive unparked accounts may be deleted."
login:
# returns "401 Unauthorized, check your credentials" if passkey is invalid
path: "rss.php?apikey={{ .Config.apikey }}&view=html"
method: get
search:
paths:
- path: rss.php
response:
type: xml
inputs:
apikey: "{{ .Config.apikey }}"
t: "{{ if or .Query.Season .Query.Ep }}tvsearch{{ else }}search{{ end }}"
q: "{{ .Keywords }}"
cat: "{{ join .Categories \",\" }}"
season: "{{ .Query.Season }}"
ep: "{{ .Query.Ep }}"
imdbid: "{{ .Query.IMDBID }}"
limit: 100
downloadvolumefactor: "{{ if .Config.freeleech }}0{{ else }}{{ end }}"
rows:
selector: rss > channel > item
fields:
category:
selector: '[name="category"]'
attribute: value
title:
selector: title
details:
selector: link
download:
selector: enclosure
attribute: url
imdbid:
selector: '[name="imdbid"]'
attribute: value
date:
# Tue, 07 Apr 2026 20:41:08 +0000
selector: pubDate
filters:
- name: dateparse
args: "ddd, dd MMM yyyy HH:mm:ss zzz"
size:
selector: size
seeders:
selector: '[name="seeders"]'
attribute: value
leechers:
selector: '[name="leechers"]'
attribute: value
grabs:
selector: '[name="grabs"]'
attribute: value
downloadvolumefactor:
selector: '[name="downloadvolumefactor"]'
attribute: value
uploadvolumefactor:
selector: '[name="uploadvolumefactor"]'
attribute: value
minimumratio:
text: 1.0
minimumseedtime:
# 3 days (as seconds = 3 x 24 x 60 x 60)
text: 259200
# torznab xml

View File

@ -63,17 +63,17 @@ settings:
asc: asc
login:
path: https://foro.unionfansub.com/member.php
method: post
path: https://foro.unionfansub.com/member.php?action=login
method: form
form: form[action="member.php"]
inputs:
quick_username: "{{ .Config.username }}"
quick_password: "{{ .Config.password }}"
username: "{{ .Config.username }}"
password: "{{ .Config.password }}"
action: do_login
url: "{{ .Config.sitelink }}"
quick_login: 1
quick_remember: yes
remember: yes
error:
- selector: div.error
- selector: td:contains("usuario/contraseña inválida")
test:
path: /
selector: div#statusbar

View File

@ -9,7 +9,7 @@ type: public
encoding: UTF-8
# to fetch current domain use https://www.rantop.org/
links:
- https://www.zktorrent.org/
- https://www.zktorrent.cc/
legacylinks:
# latest domains list
- https://www.rantop.xyz/
@ -28,7 +28,6 @@ legacylinks:
- https://www.torrent-site.com/
- https://www.rantop.org/
# actual legacylinks
- https://www.zktorrent4.com/
- https://www.zktorrent5.com/
- https://www.zktorrent.net/
- https://www.zktorrent6.com/
@ -43,6 +42,7 @@ legacylinks:
- https://www-zktorrent.com/
- https://ww2-zktorrent.com/
- https://www.zktorrent.com/
- https://www.zktorrent.org/
caps:
categorymappings:

View File

@ -1,174 +0,0 @@
import argparse
import os
import subprocess
import logging
import sys
from datetime import datetime, timedelta
from multiprocessing import Pool
# --- Configuration ---
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
BASEDIR = "/media/seagata16t/hikvision16t"
TIMELAPSE_BASEDIR = "/media/seagata16t/all_timelapses/"
DIRS = ["reolink_4", "hikvision_2", "reolink_5", "hikvision_4", "reolink_1", "reolink_2", "reolink_3"]
DEFAULT_FPS = 30
TARGET_LEVEL = "root"
# --- Helpers ---
def get_video_duration(filepath):
"""Pobiera czas trwania wideo za pomocą ffprobe."""
cmd = [
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', filepath
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return float(result.stdout.strip())
except Exception:
return 0.0
def _chronological_dates(dates):
return sorted(dates)
# --- Core Functions ---
def create_from_images(cam_dir, date_list, period_name):
"""Tworzy timelapse z surowych zdjęć JPG."""
tmp_file_path = f'/tmp/{cam_dir}_{period_name}_images.txt'
image_count = 0
with open(tmp_file_path, 'w') as f:
for target_date in _chronological_dates(date_list):
source_path = os.path.join(BASEDIR, cam_dir, target_date)
if not os.path.exists(source_path):
continue
jpgs = sorted([os.path.join(source_path, img) for img in os.listdir(source_path)
if img.lower().endswith(".jpg") and os.path.getsize(os.path.join(source_path, img)) > 0])
for jpg_file in jpgs:
f.write(f"file '{jpg_file}'\n")
image_count += 1
if image_count == 0:
logging.warning(f"[{cam_dir}] Brak obrazów dla {period_name}")
return
output_dir = TIMELAPSE_BASEDIR if TARGET_LEVEL == 'root' else os.path.join(TIMELAPSE_BASEDIR, cam_dir)
os.makedirs(output_dir, exist_ok=True)
output_file = os.path.join(output_dir, f"timelapse_{cam_dir}_{period_name}.mp4")
ffmpeg_cmd = [
'ffmpeg', '-f', 'concat', '-safe', '0', '-i', tmp_file_path,
'-r', str(DEFAULT_FPS), '-s', 'hd1080', '-vcodec', 'libx264',
'-pix_fmt', 'yuv420p', '-y', output_file
]
logging.info(f"[{cam_dir}] Start renderowania {image_count} zdjęć...")
subprocess.run(ffmpeg_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
def merge_and_target_duration(cam_dir, date_list, period_name, target_duration_sec):
"""Łączy wideo MP4 i przyspiesza do celu."""
output_dir = TIMELAPSE_BASEDIR if TARGET_LEVEL == 'root' else os.path.join(TIMELAPSE_BASEDIR, cam_dir)
video_files = []
total_input_duration = 0.0
for target_date in _chronological_dates(date_list):
expected_video = os.path.join(output_dir, f"timelapse_{cam_dir}_{target_date}.mp4")
if os.path.exists(expected_video):
dur = get_video_duration(expected_video)
if dur > 0:
video_files.append(expected_video)
total_input_duration += dur
if not video_files:
logging.warning(f"[{cam_dir}] Brak plików MP4 w {output_dir}")
return
speed_multiplier = total_input_duration / float(target_duration_sec)
pts_value = 1 / speed_multiplier
logging.info(f"[{cam_dir}] Wejście: {total_input_duration:.1f}s | Cel: {target_duration_sec}s | Speed: {speed_multiplier:.2f}x")
list_file = f'/tmp/{cam_dir}_{period_name}_videos.txt'
with open(list_file, 'w') as f:
for v in video_files:
f.write(f"file '{v}'\n")
output_file = os.path.join(output_dir, f"timelapse_{cam_dir}_{period_name}.mp4")
ffmpeg_cmd = [
'ffmpeg', '-f', 'concat', '-safe', '0', '-i', list_file,
'-vf', f"setpts={pts_value}*PTS",
'-r', str(DEFAULT_FPS), '-an',
'-vcodec', 'libx264', '-crf', '23', '-preset', 'ultrafast', '-y',
output_file
]
subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
logging.info(f"[{cam_dir}] Gotowe: {output_file}")
# --- Paralelizacja ---
def camera_worker(args):
cam_dir, date_list, period_name, date_arg = args
try:
if date_arg == 'year':
merge_and_target_duration(cam_dir, date_list, period_name, 300)
elif date_arg == 'month':
merge_and_target_duration(cam_dir, date_list, period_name, 180)
elif date_arg == 'week':
merge_and_target_duration(cam_dir, date_list, period_name, 120)
else:
create_from_images(cam_dir, date_list, period_name)
except Exception as e:
logging.error(f"Błąd w procesie kamery {cam_dir}: {e}")
def make_timelapses(date_arg):
today = datetime.now()
date_list = []
if date_arg == 'today':
date_list = [today.strftime('%Y-%m-%d')]
period_name = date_list[0]
elif date_arg == 'week':
date_list = [(today - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7)]
period_name = f"week-{today.year}-{today.isocalendar()[1]}"
elif date_arg == 'month':
first = today.replace(day=1).date()
last_prev = first - timedelta(days=1)
date_list = [(last_prev.replace(day=1) + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(last_prev.day)]
period_name = f"month-{last_prev.year}-{last_prev.month:02d}"
elif date_arg == 'year':
ly = today.year - 1 # Szuka 2025
date_list = [(datetime(ly, 1, 1).date() + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(365)]
period_name = f"year-{ly}"
else:
date_list = [date_arg]
period_name = date_arg
# Kolejka zadań
tasks = [(cam, date_list, period_name, date_arg) for cam in DIRS]
# processes=2 - przetwarza 2 kamery naraz (optymalne dla dysku)
with Pool(processes=2) as pool:
pool.map(camera_worker, tasks)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("date")
args = parser.parse_args()
start_time = datetime.now()
logging.info(f"Rozpoczęto zadanie: {args.date}")
make_timelapses(args.date)
duration = datetime.now() - start_time
logging.info(f"Zakończono wszystko w czasie: {duration}")

55
watchdog.sh Executable file
View File

@ -0,0 +1,55 @@
#!/bin/bash
# Watchdog: sprawdza kontenery compose, restartuje dead/exited, wysyła Telegram
COMPOSE_DIR="/home/seba/mydocker"
LOG="/var/log/docker-watchdog.log"
STATE_FILE="/tmp/docker-watchdog-state"
# Telegram - z .env (grep żeby uniknąć nadpisania pustymi duplikatami)
TELEGRAM_BOT_TOKEN=$(grep -m1 '^TELEGRAM_BOT_TOKEN=.\+' "$COMPOSE_DIR/.env" | cut -d= -f2-)
TELEGRAM_CHAT_ID=$(grep -m1 '^TELEGRAM_CHAT_ID=.\+' "$COMPOSE_DIR/.env" | cut -d= -f2-)
telegram_notify() {
local msg="$1"
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
-d text="$msg" \
-d parse_mode="HTML" > /dev/null 2>&1
}
cd "$COMPOSE_DIR" || exit 1
# Pobierz listę kontenerów i ich stan
declare -A current_states
while IFS= read -r line; do
name=$(echo "$line" | awk '{print $1}')
state=$(echo "$line" | awk '{print $2}')
[ -n "$name" ] && current_states["$name"]="$state"
done < <(docker compose ps -a --format "{{.Name}} {{.State}}" 2>/dev/null | grep -v "^$")
restarted=()
for name in "${!current_states[@]}"; do
state="${current_states[$name]}"
if [[ "$state" == "exited" || "$state" == "dead" || "$state" == "created" ]]; then
echo "[$(date)] $name jest $state - restartuję..." >> "$LOG"
docker compose up -d "$name" >> "$LOG" 2>&1
new_state=$(docker inspect --format '{{.State.Status}}' "$name" 2>/dev/null)
if [[ "$new_state" == "running" ]]; then
echo "[$(date)] $name uruchomiony." >> "$LOG"
restarted+=("$name (był: $state)")
else
echo "[$(date)] $name - nie udało się uruchomić (stan: $new_state)." >> "$LOG"
restarted+=("$name BŁĄD (był: $state, teraz: $new_state)")
fi
fi
done
if [ ${#restarted[@]} -gt 0 ]; then
msg="🐳 <b>Docker watchdog - acemagic</b>%0A"
msg+="Zrestartowane kontenery:%0A"
for r in "${restarted[@]}"; do
msg+="- $r%0A"
done
telegram_notify "$msg"
fi