Compare commits
1 Commits
main
...
2026-04-09
| Author | SHA1 | Date |
|---|---|---|
|
|
2ba9f3963e |
|
|
@ -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
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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}')
|
||||
"
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue