From 1a3476c4d303b8786b16d4880634c343d08a859e Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Sat, 6 Jun 2026 00:54:07 +0200 Subject: [PATCH] Add Flask shopping app with Nomad deployment Flask app serving the Citti shopping list with SQLite persistence, per-category item addition, dynamic custom categories, photo upload, and a post-trip price analysis page. - Port 9756, DATA_DIR env var for persistent volume in Nomad - Gitea Actions pipeline builds Docker image and deploys via Nomad API - Makefile targets: run, install, build, push Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/deploy.yml | 41 +++ .gitignore | 5 + Dockerfile | 19 ++ Makefile | 16 + app.py | 267 ++++++++++++++++ citti.nomad | 50 +++ requirements.txt | 1 + templates/analyse.html | 284 +++++++++++++++++ templates/index.html | 604 ++++++++++++++++++++++++++++++++++++ 9 files changed, 1287 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 app.py create mode 100644 citti.nomad create mode 100644 requirements.txt create mode 100644 templates/analyse.html create mode 100644 templates/index.html diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..1e6567b --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,41 @@ +name: deploy + +on: + push: + branches: [main] + +env: + IMAGE: gea.i80.dk/hjess/citti + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to Gitea registry + run: | + echo "${{ secrets.GITEA_TOKEN }}" | \ + docker login gea.i80.dk -u ${{ github.actor }} --password-stdin + + - name: Build & push image + run: | + docker build \ + -t ${{ env.IMAGE }}:latest \ + -t ${{ env.IMAGE }}:${{ github.sha }} . + docker push ${{ env.IMAGE }}:latest + docker push ${{ env.IMAGE }}:${{ github.sha }} + + - name: Deploy to Nomad + env: + NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }} + NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }} + run: | + JOB_JSON=$(curl -sf "${NOMAD_ADDR}/v1/jobs/parse" \ + -H "Content-Type: application/json" \ + -d "{\"JobHCL\": $(jq -Rs . < citti.nomad)}") + curl -sf -X POST "${NOMAD_ADDR}/v1/jobs" \ + -H "X-Nomad-Token: ${NOMAD_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"Job\": ${JOB_JSON}}" + echo "✓ Deployed" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de2fbad --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +data.db +static/uploads/ +__pycache__/ +*.pyc +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..84404ae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY templates/ templates/ +COPY static/ static/ + +ENV DATA_DIR=/data +VOLUME ["/data"] +EXPOSE 9756 + +RUN useradd -m appuser && chown -R appuser /app +USER appuser + +CMD ["python", "app.py"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb7e02f --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +IMAGE ?= gea.i80.dk/hjess/citti +TAG ?= latest + +.PHONY: run install build push + +run: + python app.py + +install: + pip install -r requirements.txt + +build: + docker build -t $(IMAGE):$(TAG) . + +push: build + docker push $(IMAGE):$(TAG) diff --git a/app.py b/app.py new file mode 100644 index 0000000..54b8a63 --- /dev/null +++ b/app.py @@ -0,0 +1,267 @@ +import sqlite3 +import os +import re +import unicodedata +import socket +from pathlib import Path +from datetime import datetime +from flask import Flask, render_template, request, jsonify, send_from_directory + +app = Flask(__name__) +DATA_DIR = Path(os.environ.get("DATA_DIR", ".")) +UPLOAD_DIR = DATA_DIR / "uploads" +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +DB = str(DATA_DIR / "data.db") + +SECTIONS = [ + {"id": "koed", "emoji": "🥩", "title": "Kød & fisk", "est": 1580, "varer": [ + {"key": "entrecote", "name": "Entrecote ½ kg", "note": "Frisk sydamerikansk", "qty": "6 × ½ kg", "est": 605.94, "badges": ["card"]}, + {"key": "nakkekoteletter", "name": "Nakkekoteletter ½ kg", "note": "Med/uden krydderi", "qty": "6 × ½ kg", "est": 191.94}, + {"key": "kyllingebryst", "name": "Kyllingebryst ½ kg", "note": "Texas-marinade", "qty": "6 × ½ kg", "est": 359.94}, + {"key": "hakket-oksekoed", "name": "Hakket oksekød", "note": "Køddisk — spørg personalet", "qty": "3 kg", "est": 240}, + {"key": "rumpsteak", "name": "Rumpsteak ½ kg", "note": "Modnet, tyske kvier", "qty": "1 × ½ kg", "est": 167.99}, + {"key": "lakseside", "name": "Lakseside premium", "note": "15,99/100g · tag ~700g", "qty": "~700g", "est": 112}, + {"key": "grillpoelser", "name": "Gerstand grillpølser", "note": "10 stk. à 100g", "qty": "1 kg", "est": 74.99}, + {"key": "bacon", "name": "Steinemann bacon i skiver","note": "Røget · fryseegnet", "qty": "1 kg", "est": 74.99}, + ]}, + {"id": "frost", "emoji": "🧊", "title": "Frostgrønt · 3 kg poser", "est": 100, "varer": [ + {"key": "wok-mix", "name": "Wok-mix blandede grøntsager", "qty": "3 kg", "est": 40}, + {"key": "aerter", "name": "Ærter", "qty": "3 kg", "est": 30}, + {"key": "broccoli", "name": "Broccoli", "qty": "3 kg", "est": 35}, + {"key": "frost-andet", "name": "Hvad der ellers frister", "note": "Tjek frysedisken", "qty": "—", "est": 0}, + ]}, + {"id": "mejeri", "emoji": "🧀", "title": "Mejeri, ost & frost", "est": 185, "varer": [ + {"key": "feta", "name": "Greco Schaffeta feta 150g", "note": "70% fåremælk", "qty": "3 stk.", "est": 44.97}, + {"key": "frutti-mare", "name": "Frutti di Mare", "note": "Rejer, muslinger, blæksprutte", "qty": "1 kg", "est": 59.99}, + {"key": "tigerrejer", "name": "Sorte tigerrejer frost", "note": "Rå, pillede · wok/grill", "qty": "1 kg", "est": 79.99}, + ]}, + {"id": "pasta", "emoji": "🍝", "title": "Pasta frisk køl · Gut & Günstig ~800g", "est": 160, "varer": [ + {"key": "tortelloni-skinke", "name": "Tortelloni Skinke", "qty": "2 stk.", "est": 55}, + {"key": "tortelloni-spinat", "name": "Tortelloni Spinat & ricotta", "qty": "2 stk.", "est": 55}, + {"key": "tortelloni-valg", "name": "Tortelloni valgfri variant", "note": "Svampe, tomat-mozzarella o.l.", "qty": "2 stk.", "est": 55}, + ]}, + {"id": "groent", "emoji": "🥗", "title": "Grønt, olier & tørvarer", "est": 245, "varer": [ + {"key": "avocado", "name": "Avocado Hass", "note": "Fra Peru, SanLucar", "qty": "5 stk.", "est": 74.95}, + {"key": "olivenolie", "name": "Bertolli olivenolie extra vergine", "qty": "1L", "est": 99.99}, + {"key": "mutti", "name": "Mutti passata", "note": "Natur eller basilikum","qty": "3 × 700g", "est": 44.97}, + {"key": "rustichella", "name": "Rustichella d'Abruzzo pasta", "qty": "4 × 500g", "est": 75.96}, + ]}, + {"id": "konserves", "emoji": "🥫", "title": "Konserves — tomat & basis", "est": 185, "varer": [ + {"key": "hakkede-tomater", "name": "Hakkede tomater 400g", "qty": "8 dåser", "est": 50}, + {"key": "flaede-tomater", "name": "Flåede tomater 400g", "qty": "4 dåser", "est": 25}, + {"key": "tomatpure", "name": "Tomatpuré / koncentrat", "qty": "4 stk.", "est": 35}, + {"key": "tun", "name": "Tun i olie 80g", "qty": "6 dåser", "est": 47}, + {"key": "majs", "name": "Majs 340g", "qty": "4 dåser", "est": 28}, + ]}, + {"id": "indisk", "emoji": "🌶️", "title": "Indisk & karry", "est": 160, "varer": [ + {"key": "tikka-masala", "name": "Tikka masala sauce", "note": "350–500g · Taste of India", "qty": "5 stk.", "est": 70}, + {"key": "karrypasta-roed", "name": "Karrypasta rød 50g", "qty": "2 stk.", "est": 20}, + {"key": "karrypasta-groen", "name": "Karrypasta grøn 50g", "qty": "2 stk.", "est": 20}, + {"key": "karrypasta-gul", "name": "Karrypasta gul/mild", "qty": "2 stk.", "est": 20}, + {"key": "mango-chutney", "name": "Mango chutney 300g", "qty": "2 stk.", "est": 24}, + {"key": "basmatiris", "name": "Basmatiris 1 kg", "qty": "2 stk.", "est": 28}, + ]}, + {"id": "mex", "emoji": "🌮", "title": "Mexicansk & asiatisk", "est": 185, "varer": [ + {"key": "tacokrydderi", "name": "Tacokrydderi pakke", "qty": "6 stk.", "est": 33}, + {"key": "jalapenos", "name": "Jalapeños på glas 200g", "qty": "2 stk.", "est": 16}, + {"key": "salsa", "name": "Salsa / tomatdip 300g", "qty": "2 stk.", "est": 19}, + {"key": "tacoshells", "name": "Tacoshells / tortilla wraps","qty": "2 stk.", "est": 24}, + {"key": "kokosmalk", "name": "Kokosmælk 400ml", "qty": "6 dåser", "est": 47}, + {"key": "sojasauce", "name": "Sojasauce 250ml", "qty": "1 stk.", "est": 10}, + {"key": "bambusskud", "name": "Bambusskud / vandkastanjer","qty": "2 dåser", "est": 14}, + ]}, + {"id": "drikke", "emoji": "🍺", "title": "Drikkevarer", "est": 530, "varer": [ + {"key": "carlsberg", "name": "Carlsberg / Carls Special 0,33L", "qty": "24 stk.", "est": 79, "badges": ["kasse"]}, + {"key": "pepsi-max", "name": "Pepsi Max 1,5L", "qty": "6 stk.", "est": 49.99, "badges": ["kasse"]}, + {"key": "faxe-kondi", "name": "Faxe Kondi Appelsin 0,33L", "qty": "24 stk.", "est": 54.99, "badges": ["kasse"]}, + {"key": "kuehling-vin", "name": "Kühling hvid-/rosévin 0,75L", "qty": "4 stk.", "est": 159.96, "badges": ["card"]}, + {"key": "grauburgunder", "name": "Grauburgunder hvidvin 1,5L", "qty": "1 stk.", "est": 59.99, "badges": ["card"]}, + {"key": "kaffe", "name": "Eduscho Caffè Crema hele bønner", "qty": "1 kg", "est": 104.99}, + ]}, + {"id": "hus", "emoji": "🏠", "title": "Husholdning & skafferi", "est": 535, "varer": [ + {"key": "ariel", "name": "Ariel vaskemiddel", "note": "100–111 vaske", "qty": "1 stk.", "est": 199.90}, + {"key": "finish", "name": "Finish opvasketabs", "note": "64 stk.", "qty": "1 stk.", "est": 59.99}, + {"key": "cashew", "name": "BASE Culinar cashewnødder", "qty": "1 kg", "est": 79.99}, + {"key": "nutella", "name": "Nutella 1 kg glas", "qty": "1 stk.", "est": 49.99, "badges": ["card"]}, + {"key": "biscoff", "name": "Lotus Biscoff smørepålæg 1,6 kg", "qty": "1 stk.", "est": 99.99, "badges": ["card"]}, + {"key": "haribo", "name": "Haribo vingummi / lakrids", "note": "800–1000g", "qty": "1 stk.", "est": 44.99}, + ]}, +] + + +def slugify(text): + text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode() + return re.sub(r'[-\s]+', '-', re.sub(r'[^\w\s-]', '', text.lower())).strip('-') + + +def get_db(): + conn = sqlite3.connect(DB) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + with get_db() as c: + c.execute("""CREATE TABLE IF NOT EXISTS prices ( + key TEXT PRIMARY KEY, price REAL, checked INT DEFAULT 0, updated TEXT)""") + c.execute("""CREATE TABLE IF NOT EXISTS extras ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, section_key TEXT, price REAL, + photo TEXT, note TEXT, created TEXT)""") + c.execute("""CREATE TABLE IF NOT EXISTS custom_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + emoji TEXT DEFAULT '📦', title TEXT, created TEXT)""") + try: + c.execute("ALTER TABLE extras ADD COLUMN section_key TEXT") + except Exception: + pass + + +@app.route('/') +def index(): + with get_db() as c: + saved = {r['key']: dict(r) for r in c.execute("SELECT * FROM prices")} + extras = [dict(r) for r in c.execute("SELECT * FROM extras ORDER BY created DESC")] + custom_secs = [dict(r) for r in c.execute("SELECT * FROM custom_sections ORDER BY created")] + by_sec = {} + for e in extras: + by_sec.setdefault(e.get('section_key') or '', []).append(e) + return render_template('index.html', sections=SECTIONS, saved=saved, + by_sec=by_sec, custom_secs=custom_secs) + + +@app.route('/api/price', methods=['POST']) +def save_price(): + d = request.json + key = d.get('key') + price = d.get('price') + checked = int(d.get('checked', 0)) + if not key: + return jsonify(error='missing key'), 400 + with get_db() as c: + c.execute("""INSERT INTO prices(key,price,checked,updated) VALUES(?,?,?,?) + ON CONFLICT(key) DO UPDATE SET price=excluded.price, + checked=excluded.checked, updated=excluded.updated""", + (key, price, checked, datetime.now().isoformat())) + return jsonify(ok=True) + + +@app.route('/api/item', methods=['POST']) +def add_item(): + name = request.form.get('name', '').strip() + if not name: + return jsonify(error='name required'), 400 + price_s = request.form.get('price', '').strip().replace(',', '.') + price = float(price_s) if price_s else None + section_key = request.form.get('section_key', '').strip() or None + note = request.form.get('note', '').strip() + + photo = None + f = request.files.get('photo') + if f and f.filename: + ext = (os.path.splitext(f.filename)[1] or '.jpg').lower() + fname = f"{slugify(name)}-{int(datetime.now().timestamp())}{ext}" + f.save(UPLOAD_DIR / fname) + photo = fname + + with get_db() as c: + row = c.execute( + "INSERT INTO extras(name,section_key,price,photo,note,created) VALUES(?,?,?,?,?,?)", + (name, section_key, price, photo, note, datetime.now().isoformat())) + item_id = row.lastrowid + + return jsonify(ok=True, id=item_id, photo=photo, + name=name, section_key=section_key, price=price, note=note) + + +@app.route('/api/item/', methods=['DELETE']) +def delete_item(item_id): + with get_db() as c: + row = c.execute("SELECT photo FROM extras WHERE id=?", (item_id,)).fetchone() + if row and row['photo']: + p = UPLOAD_DIR / row['photo'] + if p.exists(): + p.unlink() + c.execute("DELETE FROM extras WHERE id=?", (item_id,)) + return jsonify(ok=True) + + +@app.route('/uploads/') +def serve_upload(filename): + return send_from_directory(UPLOAD_DIR, filename) + + +@app.route('/api/category', methods=['POST']) +def add_category(): + d = request.json + emoji = (d.get('emoji') or '📦').strip() or '📦' + title = (d.get('title') or '').strip() + if not title: + return jsonify(error='title required'), 400 + with get_db() as c: + row = c.execute("INSERT INTO custom_sections(emoji,title,created) VALUES(?,?,?)", + (emoji, title, datetime.now().isoformat())) + cat_id = row.lastrowid + return jsonify(ok=True, id=cat_id, key=f"custom-{cat_id}", emoji=emoji, title=title) + + +@app.route('/api/category/', methods=['DELETE']) +def delete_category(cat_id): + key = f"custom-{cat_id}" + with get_db() as c: + for r in c.execute("SELECT photo FROM extras WHERE section_key=?", (key,)).fetchall(): + if r['photo']: + p = UPLOAD_DIR / r['photo'] + if p.exists(): + p.unlink() + c.execute("DELETE FROM extras WHERE section_key=?", (key,)) + c.execute("DELETE FROM custom_sections WHERE id=?", (cat_id,)) + return jsonify(ok=True) + + +@app.route('/analyse') +def analyse(): + with get_db() as c: + saved = {r['key']: dict(r) for r in c.execute("SELECT * FROM prices")} + extras = [dict(r) for r in c.execute("SELECT * FROM extras ORDER BY price DESC NULLS LAST")] + custom_secs = [dict(r) for r in c.execute("SELECT * FROM custom_sections ORDER BY created")] + + rows = [] + total_est = total_actual = 0 + for sec in SECTIONS: + sec_est = sec_actual = 0 + for item in sec['varer']: + s = saved.get(item['key'], {}) + actual = s.get('price') + sec_est += item['est'] + if actual: + sec_actual += actual + rows.append({ + 'title': sec['title'], + 'emoji': sec['emoji'], + 'est': sec_est, + 'actual': sec_actual or None, + 'diff': (sec_actual - sec_est) if sec_actual else None, + }) + total_est += sec_est + if sec_actual: + total_actual += sec_actual + + return render_template('analyse.html', sections=SECTIONS, saved=saved, + extras=extras, rows=rows, custom_secs=custom_secs, + total_est=total_est, + total_actual=total_actual or None) + + +if __name__ == '__main__': + init_db() + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + except Exception: + local_ip = "127.0.0.1" + print(f"\n Computer: http://localhost:9756") + print(f" Telefon (samme WiFi): http://{local_ip}:9756\n") + app.run(host='0.0.0.0', port=9756, debug=False) diff --git a/citti.nomad b/citti.nomad new file mode 100644 index 0000000..623f093 --- /dev/null +++ b/citti.nomad @@ -0,0 +1,50 @@ +job "citti" { + datacenters = ["dc1"] + type = "service" + + group "app" { + count = 1 + + volume "citti-data" { + type = "host" + read_only = false + source = "citti-data" + } + + network { + port "http" { + static = 9756 + } + } + + task "web" { + driver = "docker" + + config { + image = "gea.i80.dk/hjess/citti:latest" + ports = ["http"] + force_pull = true + } + + volume_mount { + volume = "citti-data" + destination = "/data" + read_only = false + } + + env { + DATA_DIR = "/data" + } + + resources { + cpu = 200 + memory = 256 + } + + service { + name = "citti" + port = "http" + } + } + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..001e7c4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask>=3.0 diff --git a/templates/analyse.html b/templates/analyse.html new file mode 100644 index 0000000..75d0297 --- /dev/null +++ b/templates/analyse.html @@ -0,0 +1,284 @@ + + + + + +Citti Analyse — Uge 23-25 + + + + +
+ +
+
+

Citti Analyse

+
Uge 23–25 · 03.06–23.06.2026
+
+ ← Indkøbsseddel +
+ +{% set total_actual = total_actual or 0 %} +{% set total_est = total_est or 4065 %} +{% set diff = total_actual - total_est if total_actual else 0 %} +{% set pct = ((diff / total_est) * 100) if total_est else 0 %} + +
+
+
Faktisk betalt (Citti)
+ {% if total_actual %} +
{{ "{:,.0f}".format(total_actual).replace(",",".") }}
+
kr. registreret
+ {% else %} +
+
ingen priser indtastet endnu
+ {% endif %} +
+
+
Estimeret
+
{{ "{:,.0f}".format(total_est).replace(",",".") }}
+
kr. planlagt
+
+
+
Difference
+ {% if total_actual %} +
+ {% if diff > 0 %}+{% endif %}{{ "{:,.0f}".format(diff).replace(",",".") }} +
+
kr. ({{ "{:+.1f}".format(pct) }}%)
+ {% else %} +
+
-
+ {% endif %} +
+
+ + +
+
Kategorioversigt
+ + + + + + + + + + + {% for r in rows %} + + + + + + + {% endfor %} + + + + + + + +
KategoriEstimatFaktiskDifference
{{ r.emoji }} {{ r.title }}{{ "{:,.0f}".format(r.est).replace(",",".") }}{% if r.actual %}{{ "{:,.0f}".format(r.actual).replace(",",".") }}{% else %}{% endif %} + {% if r.diff is not none %} + + {% if r.diff > 0 %}+{% endif %}{{ "{:,.0f}".format(r.diff).replace(",",".") }} + + {% else %} + + {% endif %} +
Total Citti{{ "{:,.0f}".format(total_est).replace(",",".") }}{% if total_actual %}{{ "{:,.0f}".format(total_actual).replace(",",".") }}{% else %}—{% endif %} + {% if total_actual %} + + {% if diff > 0 %}+{% endif %}{{ "{:,.0f}".format(diff).replace(",",".") }} + + {% else %}—{% endif %} +
+
+ + +{% if total_actual %} +
+
Faktisk vs. estimat per kategori
+
+ {% for r in rows %} + {% if r.actual %} + {% set pct_bar = [((r.actual / r.est) * 100), 100]|min if r.est else 0 %} +
+
{{ r.emoji }} {{ r.title.split('·')[0].strip() }}
+
+
+
+
{{ "{:,.0f}".format(r.actual).replace(",",".") }} / {{ "{:,.0f}".format(r.est).replace(",",".") }}
+
+ {% endif %} + {% endfor %} +
+
+{% endif %} + + +
+
Alle varer — est. vs. faktisk
+ {% set has_items = saved|length > 0 %} + {% if has_items %} +
+ {% for sec in sections %} + {% for item in sec.varer %} + {% set s = saved.get(item.key, {}) %} + {% if s.get('price') %} + {% set diff_item = s.price - item.est %} +
+
{{ item.name }}
+
+ est. {{ "{:.0f}".format(item.est) }} + → {{ "{:.0f}".format(s.price) }} kr. +
+
+ {% if diff_item > 0.5 %}+{{ "{:.0f}".format(diff_item) }} kr. dyrere{% elif diff_item < -0.5 %}{{ "{:.0f}".format(diff_item) }} kr. billigere{% else %}som estimat{% endif %} +
+
+ {% endif %} + {% endfor %} + {% endfor %} +
+ {% else %} +
Ingen priser registreret endnu.
+ {% endif %} +
+ + +
+
Ekstra varer tilføjet i butikken ({{ extras|length }})
+ {% if extras %} +
+ {% for e in extras %} +
+ {% if e.photo %} + {{ e.name }} + {% else %} +
📦
+ {% endif %} +
+
{{ e.name }}
+ {% if e.price %}
{{ "{:.2f}".format(e.price).replace(".",",") }} kr.
{% endif %} +
+ {% if e.category %}{{ e.category }}{% endif %} + {% if e.note %} · {{ e.note }}{% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
Ingen ekstra varer tilføjet.
+ {% endif %} +
+ +{% if extras %} +
+
Ekstra varer — prissammenfatning
+ + + + + + {% set extra_total = namespace(v=0) %} + {% for e in extras %} + + + + + + + {% if e.price %}{% set extra_total.v = extra_total.v + e.price %}{% endif %} + {% endfor %} + {% if extra_total.v > 0 %} + + + + + + {% endif %} + +
VareKategoriPrisNote
{{ e.name }}{{ e.category or '—' }}{% if e.price %}{{ "{:.2f}".format(e.price).replace(".",",") }}{% else %}—{% endif %}{{ e.note or '' }}
Total ekstra{{ "{:.2f}".format(extra_total.v).replace(".",",") }}
+
+{% endif %} + +
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..23d7832 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,604 @@ + + + + + +Citti Indkøbsseddel — Uge 23-25 + + + + +
+ +
Gemt ✓
+ +
+
+

Citti Indkøbsseddel

+
+ Uge 23–25 · 03.06–23.06.2026 + Man–lør 08–20 + Langberger Weg 4, Flensburg + 1 EUR ≈ 7,46 DKK +
+
+
+
+ + 📊 Analyse +
+
+
Estimeret besparelse
+
~1.800
+
kr. vs. dansk pris
+
+
+
+ +
+
+
Aftensmadsdage
+
~40
+
fra én tur
+
+
+
Kødportioner
+
24
+
à 500g i fryseren
+
+
+
Estimeret total
+
~4.400
+
kr. inkl. Rema
+
+
+
Faktisk betalt
+
+
kr. hos Citti
+
+
+ + +{% for sec in sections %} +
+
+ {{ sec.emoji }} {{ sec.title }} + est. ~{{ "{:,.0f}".format(sec.est).replace(",",".") }} kr. + +
+
+
+
Vare
+
Antal
+
Estimat
+
Faktisk
+
+
+ {% for item in sec.varer %} + {% set s = saved.get(item.key, {}) %} +
+
+
+ {{ item.name }} + {% if item.get('badges') %}{% for b in item.badges %}{% if b == 'card' %}CITTI CARD{% endif %}{% if b == 'kasse' %}Hel kasse{% endif %}{% endfor %}{% endif %} + {% if item.get('note') %}{{ item.note }}{% endif %} +
+
{{ item.qty }}
+
{{ "{:.2f}".format(item.est).replace(".",",") if item.est else "—" }}
+
+ +
+
+ {% endfor %} + {% for e in by_sec.get(sec.id, []) %} +
+
+
+ {{ e.name }} + {% if e.note %}{{ e.note }}{% endif %} +
+
{% if e.price %}{{ "{:.2f}".format(e.price).replace(".",",") }}{% else %}—{% endif %}
+
+
+ {% endfor %} +
+
+{% endfor %} + + +
+{% for cs in custom_secs %} +{% set sk = "custom-" + cs.id|string %} +
+
+ {{ cs.emoji }} {{ cs.title }} + {{ by_sec.get(sk, [])|length }} vare{{ 'r' if by_sec.get(sk, [])|length != 1 else '' }} + + +
+
+ {% set sk_extras = by_sec.get(sk, []) %} + {% if sk_extras %} + {% for e in sk_extras %} +
+
+
{{ e.name }}{% if e.note %}{{ e.note }}{% endif %}
+
{% if e.price %}{{ "{:.2f}".format(e.price).replace(".",",") }}{% else %}—{% endif %}
+
+
+ {% endfor %} + {% else %} +
Ingen varer endnu — tryk + for at tilføje
+ {% endif %} +
+
+{% endfor %} +
+ + +
+
🛒 Køb i Rema1000 / Netto
+
+ {% for item in ["Frisk frugt & grøntsager","Løg & hvidløg","Kartofler","Mælk, æg, smør","Brød & pålæg","Mel, gær, bagepulver","Vaniljesukker"] %} + + {% endfor %} +
+
+ + +{% set loose = by_sec.get('', []) %} +{% if loose %} +
+
+ 📦 Diverse — uden kategori + {{ loose|length }} vare{{ 'r' if loose|length != 1 else '' }} +
+
+ {% for e in loose %} +
+
+
{{ e.name }}{% if e.note %}{{ e.note }}{% endif %}
+
{% if e.price %}{{ "{:.2f}".format(e.price).replace(".",",") }}{% else %}—{% endif %}
+
+
+ {% endfor %} +
+
+{% endif %} + + +
+
⚠ Noter
+
    +
  • Varer med "Hel kasse" sælges kun i hele kasser
  • +
  • CITTI CARD er gratis — hent ved kassen
  • +
  • Hakket oksekød: spørg i køddisken
  • +
  • Karrypasta: tjek den asiatiske afdeling
  • +
  • Husk fryseposer + marker hjemmefra — 500g portioner
  • +
+
+ + +
+
Prissammenfatning
+
+
Kategori
Estimat
Faktisk
+ {% set sec_ids = ['koed','frost','mejeri','pasta','groent','konserves','indisk','mex','drikke','hus'] %} + {% set sec_labels = ['Kød & fisk','Frostgrønt','Mejeri & frost','Pasta (frisk)','Grønt & tørvarer','Konserves','Indisk & karry','Mexicansk & asiatisk','Drikkevarer','Husholdning'] %} + {% set sec_ests = [1580,100,185,160,245,185,160,185,530,535] %} + {% for i in range(sec_ids|length) %} +
{{ sec_labels[i] }}
+
~{{ sec_ests[i] }}
+
+ {% endfor %} +
Total Citti
~4.065
+
Rema1000 supplement
~350
+
Samlet total
~4.400
+
+
+ +
Citti Flensborg · Langberger Weg 4 · Tilbud 03.06–23.06.2026
+ +
+ + + + + + + + + + + + +