Compare commits

..

No commits in common. 'main' and '26-7-Stundeneinträge' have entirely different histories.

2
.gitignore vendored

@ -2,6 +2,4 @@ venv/
*/__pycache__* */__pycache__*
*/migrations* */migrations*
*/.DS_Store */.DS_Store
/data/
db.sqlite3

@ -1,175 +1,11 @@
# PIOMINT # PIOMINT
Track every hours with minty Fresh Efficiency!
Track every hour with minty fresh efficiency! 🌿 ## AppIdee
Es gibt einige Zeit-Tracker Apps für Pioniere. Leide sind die guten in der Regel nur auf Englisch erhältlicht und viel zu Umständlich.
Eine schlanke Progressive Web App für Pioniere, um Dienststunden, Bibelstudien und ## Das Ziel
Bemerkungen unkompliziert zu erfassen – und den Monatsbericht mit einem Klick fertig Eine einfache App mit einem freshen UI mit der es leicht fällt die Stunden aufzuschreiben und pünktlich abzugeben.
formatiert für WhatsApp zu teilen.
---
## Inhalt
- [Über das Projekt](#über-das-projekt)
- [Aktueller Stand](#aktueller-stand)
- [Features](#features)
- [Tech-Stack](#tech-stack)
- [Projektstruktur](#projektstruktur)
- [Lokale Entwicklung](#lokale-entwicklung)
- [Daten & Datenschutz](#daten--datenschutz)
- [Versionierung & Deployment](#versionierung--deployment)
- [Roadmap / offene Punkte](#roadmap--offene-punkte)
- [Mitwirken](#mitwirken)
- [Lizenz](#lizenz)
---
## Über das Projekt
Es gibt einige Zeit-Tracker-Apps für Pioniere. Leider sind die guten in der Regel nur
auf Englisch erhältlich und viel zu umständlich.
**Das Ziel:** eine einfache App mit einem frischen UI, mit der es leicht fällt, die
Stunden aufzuschreiben und pünktlich abzugeben – als installierbare PWA, komplett auf
Deutsch, ohne Account-Zwang.
## Aktueller Stand ## Aktueller Stand
GANZ AM ANFANG :D
**Version 26.0** – in aktivem Einsatz. Aus dem "GANZ AM ANFANG"-Prototyp ist eine
funktionsreiche PWA geworden (siehe [Features](#features)). Läuft produktiv unter
[app.piomint.de](https://app.piomint.de).
## Features
- **Dienstjahr statt Kalenderjahr** – rechnet korrekt von September bis August, inkl.
Navigation beliebig weit vor und zurück über Jahresgrenzen hinweg
- **Monats- & Jahresübersicht** mit Fortschrittsbalken (600h-Ziel) und individuellem
Monatsziel
- **55h-Regel** (optional, in den Einstellungen aktivierbar): deckelt Monate mit
LDC-/Sonstiges-Anteil bei der Jahressumme automatisch auf 55h
- **Schnellwahl-Feld** (früher fix "LDC") – Name frei in den Einstellungen anpassbar
- **Aktivitäten-Tabelle** mit Sortierung (Datum/Dauer/Typ, auf-/absteigend),
ein-/ausblendbaren Filteroptionen und Maximieren-Ansicht für mehr Überblick
- **Bericht teilen** – ein Klick kopiert den Monatsbericht fertig für WhatsApp
formatiert in die Zwischenablage (inkl. Vorschau-Dialog)
- **Timer** zur direkten Zeiterfassung
- **Installierbare PWA** (Homescreen-Icon, Standalone-Modus, Splashscreen) mit
automatischem Update-Hinweis bei neuer Version
- Alle Daten liegen **lokal auf dem Gerät** (IndexedDB) – siehe
[Daten & Datenschutz](#daten--datenschutz)
## Tech-Stack
| Bereich | Technologie |
|---|---|
| Backend | Django 5.2 (`django-pwa` für Manifest/Service-Worker-Einbindung) |
| Datenhaltung (Nutzerdaten) | IndexedDB im Browser (kein serverseitiges Datenmodell) |
| Datenbank (Backend) | SQLite lokal / MySQL in Produktion |
| Frontend | Vanilla JavaScript, Bootstrap (CSS + JS-Bundle) |
| Deployment | Gunicorn |
| Sprache | Deutsch (`LANGUAGE_CODE = 'de'`) |
Es gibt bewusst kein Frontend-Build-Setup (kein npm/webpack) – die JS-Dateien unter
`app/static/app/js/` werden direkt vom Browser geladen.
## Projektstruktur
```
piomint/
├── piomint/ # Django-Projekt (Settings, URLs, PWA-Konfiguration)
│ ├── settings.py
│ ├── pwa_settings.py # App-Name, Icons, Start-URL, Theme-Farben
│ └── urls.py
├── app/ # Hauptanwendung (Dashboard, Tracking, Einstellungen)
│ ├── views.py # Versionierte Routen (siehe Versionierung & Deployment)
│ ├── urls.py
│ ├── templates/app/ # base.html (Header/Settings), home.html (Dashboard), ...
│ └── static/app/
│ ├── js/ # dbcontrol.js (IndexedDB), home01.js (UI-Logik), ...
│ ├── css/
│ └── images/
├── web/ # Öffentliche Landingpage (piomint.de)
├── doc/ # Platzhalter-App, aktuell ungenutzt
└── requirements.txt
```
## Lokale Entwicklung
Voraussetzung: **Python 3.10+** (Django 5.2 erfordert das; auf macOS zeigt `python3`
je nach System noch auf eine ältere Version – im Zweifel `python3 --version` prüfen und
notfalls `python3.12`/`python3.13` explizit installieren, z. B. via
`brew install python@3.12`).
```bash
git clone https://git.samuelzielke.de/samuelzielke/piomint.git
cd piomint
python3.12 -m venv venv # ggf. durch die passende 3.10+-Version ersetzen
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
```
Die App läuft dann unter `http://127.0.0.1:8000/app/` (aktuelle Version, siehe unten).
Für die Backend-Datenbank reicht lokal SQLite (Standardeinstellung in
`piomint/settings.py`) – die eigentlichen Nutzerdaten (Stunden, Einstellungen) landen
ohnehin im Browser, nicht in dieser DB.
## Daten & Datenschutz
PioMint speichert alle erfassten Stunden, Einstellungen und Bevorzugungen **lokal im
Browser** (IndexedDB + `localStorage`) – es gibt aktuell keinen Account und keine
Server-Synchronisation. Das bedeutet:
- Kein Tracking, keine personenbezogenen Daten auf dem Server
- **Löscht man die App/den Browser-Speicher, sind alle Einträge unwiderruflich weg** –
ein manuelles Backup (Export-Funktion in den Einstellungen) ist bis zu einer
Server-Synchronisation die einzige Absicherung
## Versionierung & Deployment
Jede Version bekommt ihre eigene URL nach dem Muster `/app/v<version>/`
(z. B. `/app/v260/` für 26.0), definiert in `app/urls.py` / `app/views.py`. Der Grund:
die PWA merkt sich beim Hinzufügen zum Homescreen eine feste `start_url`
(`PWA_APP_START_URL` in `piomint/pwa_settings.py`). Damit Nutzer:innen nach einem
Update nicht auf einer eingefrorenen alten Version hängen bleiben, leiten **alle**
bisherigen Versions-URLs auf die aktuellste weiter.
**Checkliste für ein neues Release:**
1. Neue View + URL `v<neue Version>` in `app/views.py` / `app/urls.py` anlegen
2. Alle bisherigen Versions-Views auf die neue Route umbiegen (`redirect(...)`)
3. `PWA_APP_START_URL` in `piomint/pwa_settings.py` auf die neue Route setzen
4. `versionnumber` in `app/templates/app/updateinfo.html` hochzählen
5. Changelog-Text in `updateinfo.html` schreiben (erscheint als Update-Hinweis beim
ersten Öffnen der neuen Version)
6. **`STATIC_VERSION` in `piomint/settings.py` hochzählen** – hängt als `?v=...` an
allen CSS/JS-`{% static %}`-Links (siehe `piomint/context_processors.py`) und sorgt
dafür, dass bereits installierte Geräte garantiert die neuen Dateien laden statt eine
alte, gecachte Version zu behalten. Ohne diesen Schritt können CSS/JS und HTML
auseinanderlaufen (führte schon zu eingefrorenen Screens bei Bestandsnutzern) –
**nicht vergessen!**
7. Auf dem Server deployen: neuen Code holen (`git pull`), **falls sich Models geändert
haben** `python manage.py makemigrations app && python manage.py migrate` ausführen
(Migrationen sind nicht im Repo, siehe `.gitignore`), Static Files einsammeln
(`python manage.py collectstatic`), Gunicorn neu starten
## Roadmap / offene Punkte
- Server-seitige Synchronisation / Account-System (aktuell rein lokale Speicherung)
- Einstellbares Rundungsverhalten (aktuell fix: Aufrundung ab 15 Minuten)
- `doc`-App ist als Platzhalter angelegt, aber noch ohne Inhalt/Routing
## Mitwirken
Feedback, Bug-Reports und Ideen gerne über die
[WhatsApp-Community](https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ), per E-Mail an
**app-feedback@zielke.studio** (auch direkt in der App unter Einstellungen → Kontakt
verlinkt) oder direkt an Samuel.
## Lizenz
Bisher keine Lizenz festgelegt – alle Rechte liegen beim Autor.

@ -1,12 +1,3 @@
from django.contrib import admin from django.contrib import admin
from .models import UsageEvent # Register your models here.
@admin.register(UsageEvent)
class UsageEventAdmin(admin.ModelAdmin):
list_display = ("device_id", "created_at", "app_version", "platform", "browser", "is_standalone")
list_filter = ("platform", "browser", "is_standalone", "app_version")
search_fields = ("device_id",)
date_hierarchy = "created_at"
ordering = ("-created_at",)

@ -1,27 +1,3 @@
from django.db import models from django.db import models
# Create your models here.
class UsageEvent(models.Model):
"""Anonymes Nutzungssignal für das Admin-Dashboard (siehe home01.js: pingUsageIfNeeded()).
Ein Eintrag pro Gerät und Tag - device_id ist eine zufällige, nur lokal im Browser
(localStorage) gespeicherte ID ohne jeden Bezug zu einer echten Person. Plattform und
Browser werden serverseitig aus dem User-Agent-Header grob abgeleitet (siehe app/utils.py).
"""
device_id = models.CharField(max_length=64, db_index=True)
app_version = models.CharField(max_length=20, blank=True)
user_agent = models.TextField(blank=True)
platform = models.CharField(max_length=40, blank=True)
browser = models.CharField(max_length=40, blank=True)
is_standalone = models.BooleanField(
default=False,
help_text="True, wenn als installierte PWA (Homescreen) geöffnet statt im Browser-Tab.",
)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.device_id[:8]}… – {self.created_at:%d.%m.%Y %H:%M}"

@ -38,29 +38,45 @@
@keyframes openSettingsbg { @keyframes openSettingsbg {
0% { 0% {
height: 8vh; height: 7vh;
} }
100% { 100% {
height: 100vh; height: 100vh;
} }
} }
/* rem statt vh: die Monatskarte soll auf jedem Gerät gleich groß aussehen ("fix"), nur die
Aktivitäten-Tabelle (flex:1 1 auto, siehe unten) gleicht unterschiedliche Displayhöhen aus. */
@keyframes openMonthArea { @keyframes openMonthArea {
0% { 0% {
height: 6.5rem; height: 15vh;
margin-top: 1.5rem; margin-top: 1.5rem;
} }
100% { 100% {
height: 9.5rem; height: 20vh;
margin-top: 1.5rem; margin-top: 1.5rem;
} }
} }
@keyframes shrinkActivitys {
0% {
height: 40vh;
}
100% {
height: 35vh;
}
}
@keyframes growActivitys {
0% {
height: 35vh;
}
100% {
height: 40vh;
}
}
@keyframes openSettingscolor { @keyframes openSettingscolor {
0% { 0% {
height: 7vh; height: 6vh;
} }
100% { 100% {
height: 95vh; height: 95vh;
@ -72,7 +88,7 @@
height: 95vh; height: 95vh;
} }
100% { 100% {
height: 7vh; height: 6vh;
} }
} }
@ -81,27 +97,27 @@
height: 100vh; height: 100vh;
} }
100% { 100% {
height: 8vh; height: 7vh;
} }
} }
@keyframes closeMonthArea { @keyframes closeMonthArea {
0% { 0% {
height: 9.5rem; height: 20vh;
margin-top: 1.5rem; margin-top: 1.5rem;
} }
100% { 100% {
height: 6.5rem; height: 15vh;
margin-top: 1.5rem; margin-top: 1.5rem;
} }
} }
.HeaderDefaultHeightbg { .HeaderDefaultHeightbg {
height: 8vh; height: 7vh;
} }
.HeaderDefaultHeightcolor { .HeaderDefaultHeightcolor {
height: 7vh; height: 6vh;
} }
.slide-out { .slide-out {
@ -129,6 +145,14 @@
animation: closeMonthArea 1s forwards; animation: closeMonthArea 1s forwards;
} }
.shrinkActivitys {
animation: shrinkActivitys 1s forwards;
}
.growActivitys {
animation: growActivitys 1s forwards;
}
.closeSettingsbg { .closeSettingsbg {
animation: closeSettingsbg 1s forwards; animation: closeSettingsbg 1s forwards;
} }
@ -146,7 +170,7 @@
} }
#dashbord_month_area { #dashbord_month_area {
height: 6.5rem; height: 15vh;
} }
/* Neu hinzugefügte Icons (Filter-Toggle, Maximieren) in Grau statt Schwarz/Weiß. /* Neu hinzugefügte Icons (Filter-Toggle, Maximieren) in Grau statt Schwarz/Weiß.
@ -176,9 +200,13 @@
pointer-events: none; pointer-events: none;
} }
/* Kein fester height-Override beim Maximieren mehr nötig: ActivitysTable ist flex:1 1 auto #ActivitysTable {
(siehe #dashboard) und wächst automatisch in den Platz, den die kollabierende Timer-Karte transition: height 0.4s ease-in-out;
freigibt - inklusive sauberer Animation, weil beides gleichzeitig neu layoutet wird. */ }
#ActivitysTable.ActivitysTableMaximized {
height: 65vh !important;
}
/* Filter/Sortierung ein- und ausblenden. /* Filter/Sortierung ein- und ausblenden.
overflow bleibt im geöffneten Zustand "visible", damit das Dropdown-Menü (sortFieldMenu) overflow bleibt im geöffneten Zustand "visible", damit das Dropdown-Menü (sortFieldMenu)
@ -220,58 +248,9 @@
} }
.maincontainer { .maincontainer {
height: 92vh; height: 93vh;
transition: height 1s ease, transform 1s ease; transition: height 1s ease, transform 1s ease;
margin: 0; margin: 0;
/* Grundsätzlicher Fix für kleinere Displays (z.B. iPhone 12 mini vs. 15 Pro): die feste
vh-Höhe reicht auf kürzeren Viewports nicht für den ganzen Karten-Stapel, und body hat
overflow:hidden (verhindert Bounce/Pull-to-refresh) - ohne dieses overflow-y wäre alles
unterhalb des sichtbaren Bereichs dauerhaft unerreichbar statt nur eng. */
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
/* align-self statt der geerbten align-items-center des Elternelements: sonst würde
überlaufender Inhalt (auf kleinen Displays) mittig "geclippt" und wäre weder oben
noch unten per Scroll erreichbar. update_content bekommt das ebenfalls, da die
Update-Infos (siehe unten) jetzt deutlich mehr Inhalt haben. welcome_content bleibt
kurz genug, um weiterhin mittig zentriert zu sein. */
#dashboard,
#update_content {
align-self: flex-start;
width: 100%;
}
/* Jahr/Monat/Timer bleiben "fix" (rem-/inhaltsbasiert, gerätunabhängig gleich groß).
Die Aktivitäten-Tabelle (flex:1 1 auto, siehe ihre eigene Regel) gleicht als einziges
Element unterschiedliche Displayhöhen aus, statt dass wir jede Karte einzeln pro
Gerät neu austarieren müssen. */
#dashboard {
display: flex;
flex-direction: column;
height: 100%;
}
/* "Wow-Moment" beim Öffnen der Update-Infos: sanftes Herein-Poppen statt einfach da zu sein. */
@keyframes updateWow {
0% { opacity: 0; transform: scale(0.85) translateY(20px); }
60% { opacity: 1; transform: scale(1.03) translateY(-4px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
#update_content {
animation: updateWow 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes emojiPulse {
0%, 100% { transform: scale(1) rotate(0deg); }
50% { transform: scale(1.15) rotate(-8deg); }
}
.wow-emoji {
display: inline-block;
animation: emojiPulse 1.6s ease-in-out infinite;
} }
.bg-white-color { .bg-white-color {
@ -310,16 +289,3 @@ a:hover {
font-weight: bold; font-weight: bold;
border-radius: 25px 0 0 25px; /* Runde Ecken auf der linken Seite */ border-radius: 25px 0 0 25px; /* Runde Ecken auf der linken Seite */
} }
/* Datumsfeld (Neuer Eintrag / Bearbeiten): Safari/iOS ignoriert bei input[type=date]
die gesetzte width:100% und rendert das Feld in seiner nativen Breite, wodurch es
über den Rand der Modal-Karte hinausläuft. -webkit-appearance: none nimmt dem
Feld die native Breitenberechnung und lässt es sich wie ein normales .form-control
verhalten. */
input[type="date"].form-control {
width: 100%;
max-width: 100%;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

@ -4,7 +4,6 @@ function setUpdateOk() {
document.getElementById('update_content').style.display = 'none'; document.getElementById('update_content').style.display = 'none';
document.getElementById('dashboard').classList.remove('d-none'); document.getElementById('dashboard').classList.remove('d-none');
document.getElementById('refresh_icon').classList.remove('d-none') document.getElementById('refresh_icon').classList.remove('d-none')
document.getElementById('share_icon').classList.remove('d-none')
document.getElementById('settings_icon').classList.remove('d-none') document.getElementById('settings_icon').classList.remove('d-none')
} }

@ -294,7 +294,7 @@ function filterEntriesByServiceYear(serviceYearLabel) {
// Tabelle auf dem Dashboard mit Infos füllen // Tabelle auf dem Dashboard mit Infos füllen
// sortField: 'date' | 'duration' | 'type' - sortDirection: 'asc' | 'desc' // sortField: 'date' | 'duration' | 'type' - sortDirection: 'asc' | 'desc'
function fillTableWithEntries(entries, sortField = 'date', sortDirection = 'asc', ldcLabel = 'LDC') { function fillTableWithEntries(entries, sortField = 'date', sortDirection = 'asc') {
const tableBody = document.getElementById('entriesTableBody'); const tableBody = document.getElementById('entriesTableBody');
const sortedEntries = entries.slice().sort((a, b) => { const sortedEntries = entries.slice().sort((a, b) => {
@ -357,18 +357,10 @@ function fillTableWithEntries(entries, sortField = 'date', sortDirection = 'asc'
if (entry.type === 3) { if (entry.type === 3) {
typeCell.textContent = entry.sondertext; // Bei Type 3 zeige "Beschreibung" an typeCell.textContent = entry.sondertext; // Bei Type 3 zeige "Beschreibung" an
} else if (entry.type === 2) { } else if (entry.type === 2) {
typeCell.textContent = ldcLabel; // Ansonsten zeige den Type-Wert (individuell umbenennbar, siehe Einstellungen) typeCell.textContent = 'LDC'; // Ansonsten zeige den Type-Wert
} else if (entry.type === 1) { } else if (entry.type === 1) {
typeCell.textContent = 'Dienst'; // Ansonsten zeige den Type-Wert typeCell.textContent = 'Dienst'; // Ansonsten zeige den Type-Wert
} }
// Partner (optional) klein darunter anzeigen, falls beim Eintrag angegeben
if (entry.partner) {
const partnerLine = document.createElement('small');
partnerLine.classList.add('text-muted');
partnerLine.style.display = 'block';
partnerLine.textContent = `mit ${entry.partner}`;
typeCell.appendChild(partnerLine);
}
row.appendChild(typeCell); row.appendChild(typeCell);
const editCell = document.createElement('td'); const editCell = document.createElement('td');
@ -379,7 +371,6 @@ function fillTableWithEntries(entries, sortField = 'date', sortDirection = 'asc'
document.getElementById('editStunden').value = durationDBtoForm(entry.duration)[0]; document.getElementById('editStunden').value = durationDBtoForm(entry.duration)[0];
document.getElementById('editMinuten').value = durationDBtoForm(entry.duration)[1]; document.getElementById('editMinuten').value = durationDBtoForm(entry.duration)[1];
document.getElementById('editDatum').value = entry.date; document.getElementById('editDatum').value = entry.date;
document.getElementById('editPartnerInput').value = entry.partner || '';
if (entry.type === 2) { if (entry.type === 2) {
document.getElementById('editldcCheck').checked = true; document.getElementById('editldcCheck').checked = true;
} else { } else {

@ -4,7 +4,6 @@ const mins = document.getElementById('minuten');
const NewEntryButton = document.getElementById('newEntrySubmitButton') const NewEntryButton = document.getElementById('newEntrySubmitButton')
const SETStudySaveButton = document.getElementById('SETStudySaveButton') const SETStudySaveButton = document.getElementById('SETStudySaveButton')
const SETMonthGoalSaveButton = document.getElementById('SETMonthGoalSaveButton') const SETMonthGoalSaveButton = document.getElementById('SETMonthGoalSaveButton')
const SETLdcNameSaveButton = document.getElementById('SETLdcNameSaveButton')
const rule55hCheck = document.getElementById('rule55hCheck') const rule55hCheck = document.getElementById('rule55hCheck')
const openViewOfEntry = document.getElementById('openViewOfEntry') const openViewOfEntry = document.getElementById('openViewOfEntry')
const openViewOfEntryIcon = document.getElementById('openViewOfEntryIcon') const openViewOfEntryIcon = document.getElementById('openViewOfEntryIcon')
@ -18,10 +17,6 @@ const sortFieldMenu = document.getElementById('sortFieldMenu')
const sortFieldOptions = document.querySelectorAll('.sortFieldOption') const sortFieldOptions = document.querySelectorAll('.sortFieldOption')
const sortDirectionToggle = document.getElementById('sortDirectionToggle') const sortDirectionToggle = document.getElementById('sortDirectionToggle')
const sortDirectionIcon = document.getElementById('sortDirectionIcon') const sortDirectionIcon = document.getElementById('sortDirectionIcon')
const shareIcon = document.getElementById('share_icon')
const shareReportModalEl = document.getElementById('shareReportModal')
const shareReportStatus = document.getElementById('shareReportStatus')
const shareReportPreview = document.getElementById('shareReportPreview')
var allowToSend = false; var allowToSend = false;
const monate = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]; const monate = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
const currentDate = new Date(); const currentDate = new Date();
@ -30,83 +25,23 @@ const currentDate = new Date();
// live aktualisiert, damit getActualDataAndFillView() sie synchron verwenden kann. // live aktualisiert, damit getActualDataAndFillView() sie synchron verwenden kann.
var rule55Active = false; var rule55Active = false;
// Individuell vergebbarer Name für "LDC" (SettingsID 4), überall im UI verwendet statt fest "LDC".
var ldcLabel = 'LDC';
// Sortierung der Aktivitäten-Tabelle: wird aus dem Cache (localStorage) geladen/gespeichert. // Sortierung der Aktivitäten-Tabelle: wird aus dem Cache (localStorage) geladen/gespeichert.
var sortField = 'date'; var sortField = 'date';
var sortDirection = 'asc'; var sortDirection = 'asc';
// Ob die Filter/Sortier-Optionen gerade sichtbar sind (wird ebenfalls im Cache gespeichert). // Ob die Filter/Sortier-Optionen gerade sichtbar sind (wird ebenfalls im Cache gespeichert).
// Standardmäßig ausgeblendet, bis der Nutzer sie einmal selbst einblendet. var isFilterVisible = true;
var isFilterVisible = false;
// Ob die Aktivitäten-Tabelle gerade maximiert ist (Timer-Karte ist dann ausgeblendet). // Ob die Aktivitäten-Tabelle gerade maximiert ist (Timer-Karte ist dann ausgeblendet).
var isTableMaximized = false; var isTableMaximized = false;
// Einträge des gerade angezeigten Monats, für den WhatsApp-Bericht (share_icon) wiederverwendet.
var currentMonthEntries = [];
// //
// Trigger // Trigger
// //
//
// ANONYME NUTZUNGSSTATISTIK (Admin-Dashboard)
//
// Liest die zufällige Geräte-ID aus dem localStorage oder legt einmalig eine neue an.
// Kein Bezug zu einer Person - rein lokal generiert, dient nur der Dashboard-Zählung.
function getOrCreateDeviceId() {
try {
let id = localStorage.getItem('piomintDeviceId')
if (!id) {
id = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`
localStorage.setItem('piomintDeviceId', id)
}
return id
} catch (error) {
return null
}
}
// Meldet höchstens einmal pro Gerät und Tag ein anonymes "App genutzt"-Signal ans Backend.
// Darf die App unter keinen Umständen blockieren oder zum Absturz bringen - daher überall try/catch.
function pingUsageIfNeeded() {
try {
const today = new Date().toISOString().slice(0, 10)
if (localStorage.getItem('piomintLastPing') === today) {
return
}
const deviceId = getOrCreateDeviceId()
if (!deviceId) {
return
}
const standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || window.navigator.standalone === true
fetch('/app/track/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: deviceId,
app_version: document.body.dataset.appVersion || '',
standalone: standalone,
}),
}).then(() => {
localStorage.setItem('piomintLastPing', today)
}).catch(() => {
// Kein Netz/Fehler beim Tracking - einfach beim nächsten Öffnen erneut versuchen
})
} catch (error) {
console.error('Nutzungssignal konnte nicht gesendet werden:', error)
}
}
// Wenn das Dokument komplett geladen ist // Wenn das Dokument komplett geladen ist
document.addEventListener('DOMContentLoaded', (event) => { document.addEventListener('DOMContentLoaded', (event) => {
pingUsageIfNeeded()
initDB().then(async () => { initDB().then(async () => {
console.log('IndexedDB erfolgreich initialisiert.'); console.log('IndexedDB erfolgreich initialisiert.');
// Hier kannst du die App weiter initialisieren, z.B. Daten laden oder die UI aufbauen // Hier kannst du die App weiter initialisieren, z.B. Daten laden oder die UI aufbauen
@ -114,9 +49,6 @@ document.addEventListener('DOMContentLoaded', (event) => {
// 55h-Regel Einstellung laden, bevor die erste Jahresansicht berechnet wird // 55h-Regel Einstellung laden, bevor die erste Jahresansicht berechnet wird
await loadRule55Setting(); await loadRule55Setting();
// LDC-Bezeichnung laden, bevor die Tabelle erstmalig gefüllt wird
await loadLdcLabel();
// Gespeicherte Sortierung der Aktivitäten-Tabelle aus dem Cache laden // Gespeicherte Sortierung der Aktivitäten-Tabelle aus dem Cache laden
loadSortPreference(); loadSortPreference();
@ -132,18 +64,6 @@ document.addEventListener('DOMContentLoaded', (event) => {
}); });
}); });
// Behebt die Bootstrap-Konsolenwarnung "Blocked aria-hidden on an element because its
// descendant retained focus": beim Schließen eines Modals (z.B. per "X"-Button) setzt
// Bootstrap aria-hidden="true", obwohl der geklickte Button noch fokussiert ist. Fix: Fokus
// vor dem Ausblenden gezielt vom Modal wegnehmen. Gilt für alle Modals der Seite.
document.querySelectorAll('.modal').forEach(modalEl => {
modalEl.addEventListener('hide.bs.modal', () => {
if (document.activeElement && modalEl.contains(document.activeElement)) {
document.activeElement.blur()
}
})
})
// Lädt die 55h-Regel Einstellung (SettingsID 3) aus der DB, setzt rule55Active // Lädt die 55h-Regel Einstellung (SettingsID 3) aus der DB, setzt rule55Active
// und synchronisiert die Checkbox sowie den Badge in der Jahres-Card. // und synchronisiert die Checkbox sowie den Badge in der Jahres-Card.
async function loadRule55Setting() { async function loadRule55Setting() {
@ -327,7 +247,7 @@ NewEntryButton.addEventListener('click', () => {
} }
// Berechnen der Duration (Stunden sind optional -> ohne Eingabe wird 0 gespeichert) // Berechnen der Duration (Stunden sind optional -> ohne Eingabe wird 0 gespeichert)
const duration = ((parseInt(hours.value) || 0)*60) + (parseInt(mins.value) || 0) const duration = ((parseInt(hours.value) || 0)*60) + parseInt(mins.value)
const newEntryData = { const newEntryData = {
date: document.getElementById('datum').value, date: document.getElementById('datum').value,
@ -335,7 +255,6 @@ NewEntryButton.addEventListener('click', () => {
type: type, type: type,
status: 1, status: 1,
sondertext: document.getElementById('sonstigesInput').value, sondertext: document.getElementById('sonstigesInput').value,
partner: document.getElementById('partnerInput').value.trim(),
} }
addEntry(newEntryData, 'hours').then((id) => { addEntry(newEntryData, 'hours').then((id) => {
@ -346,7 +265,6 @@ NewEntryButton.addEventListener('click', () => {
hours.value = null hours.value = null
mins.value = null mins.value = null
document.getElementById('sonstigesInput').value = null document.getElementById('sonstigesInput').value = null
document.getElementById('partnerInput').value = null
checkInput() checkInput()
} }
}) })
@ -424,127 +342,6 @@ SETMonthGoalSaveButton.addEventListener('click', () => {
SETMonthGoalSaveButton.classList.add('d-none') SETMonthGoalSaveButton.classList.add('d-none')
}) })
// Lädt die individuelle LDC-Bezeichnung (SettingsID 4) aus der DB, setzt ldcLabel,
// füllt das Einstellungen-Feld vor und wendet den Namen überall im UI an.
async function loadLdcLabel() {
const result = await getAllEntries('settings')
const setting = result.find(element => element.SettingsID === 4)
ldcLabel = (setting == undefined || !setting.SettingsValue) ? 'LDC' : setting.SettingsValue
document.getElementById('ldcNameInput').value = ldcLabel
applyLdcLabel()
}
// Setzt die aktuelle LDC-Bezeichnung überall dort, wo sie im UI (außerhalb der Tabelle) auftaucht.
// Die Tabelle selbst bekommt den Namen bei jedem Render über fillTableWithEntries(...).
function applyLdcLabel() {
document.getElementById('ldcCheckLabel').innerText = ldcLabel
document.getElementById('editldcCheckLabel').innerText = ldcLabel
document.getElementById('monthHourLDCLabel').innerText = ldcLabel
}
SETLdcNameSaveButton.addEventListener('click', () => {
getAllEntries('settings')
.then(function(result) {
const SETDBData = result.find(element => element.SettingsID === 4)
actualSettingsEntryID = SETDBData ? SETDBData.id : undefined;
actualSettings = SETDBData ? SETDBData.SettingsValue : undefined;
// AKTUELLE DATEN AUS FELD ÜBERNHEMEN - bei leerem Feld auf "LDC" zurückfallen
var ActInput = document.getElementById('ldcNameInput').value.trim() || 'LDC'
document.getElementById('ldcNameInput').value = ActInput
const LdcNameSettingsData = {
SettingsID: 4,
SettingsValue: ActInput,
}
// ÜBERPRÜFEN OB EIN EINTRAG MTI DER SETTINGS ID VORHANDEN IST - WENN JA DANN UPDATE WENN NEIN DANN NEUEN ERSTELLEN
if ( actualSettings != undefined ) {
updateEntry(actualSettingsEntryID, 'settings', LdcNameSettingsData)
} else {
addEntry(LdcNameSettingsData, 'settings')
}
ldcLabel = ActInput
applyLdcLabel()
// Tabelle neu füllen, damit die Typ-Spalte den neuen Namen sofort übernimmt
getActualDataAndFillView(cursorMonth, cursorYear)
})
SETLdcNameSaveButton.classList.add('d-none')
})
//
// BERICHT FÜR WHATSAPP TEILEN
//
// Baut den vorformatierten Bericht für den gerade angezeigten Monat (WhatsApp-Formatierung: *fett*)
function buildMonthReportText() {
const monthLabel = `${monate[cursorMonth]} ${cursorYear}`
const dienstHours = document.getElementById('monthHourService').innerText
const bibelstudien = document.getElementById('studysinput').value
const ldcHours = document.getElementById('monthHourLDC').innerText
// Sonstige Einträge nach ihrem individuellen Bemerkungstext gruppieren und Stunden je Gruppe summieren
const sonstigeGroups = {}
currentMonthEntries
.filter(entry => entry.type === 3)
.forEach(entry => {
const label = entry.sondertext || 'Sonstiges'
sonstigeGroups[label] = (sonstigeGroups[label] || 0) + entry.duration
})
var message = `Bericht: *${monthLabel}*\n`
message += `Dienst: ${dienstHours}h\n`
message += `Bibelstudien: ${bibelstudien}\n`
message += `Bemerkungen:\n`
message += `• ${ldcLabel}: ${ldcHours}h`
Object.keys(sonstigeGroups).forEach(label => {
message += `\n• ${label}: ${formatDuration(sonstigeGroups[label], true)}h`
})
return message
}
// Kopiert Text in die Zwischenablage - mit Fallback für Umgebungen ohne Clipboard-API
async function copyTextToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
return
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
// Bestätigung als Vorschau-Modal statt kurzem Icon-Blitz: so sieht man den Bericht direkt
// (auch praktisch, wenn man ihn gar nicht kopieren, sondern nur ansehen möchte) und kann
// in Ruhe wieder schließen.
shareIcon.addEventListener('click', () => {
const message = buildMonthReportText()
shareReportPreview.innerText = message
copyTextToClipboard(message)
.then(() => {
shareReportStatus.innerText = '✓ In die Zwischenablage kopiert'
shareReportStatus.style.color = 'rgb(120,170,86)'
})
.catch((error) => {
console.error('Bericht konnte nicht in die Zwischenablage kopiert werden:', error)
shareReportStatus.innerText = 'Kopieren nicht möglich - Text unten manuell markieren'
shareReportStatus.style.color = '#dc3545'
})
.finally(() => {
new bootstrap.Modal(shareReportModalEl).show()
})
})
async function getActualDataAndFillView(monat = currentDate.getMonth(), jahr = currentDate.getFullYear()) { async function getActualDataAndFillView(monat = currentDate.getMonth(), jahr = currentDate.getFullYear()) {
try { try {
@ -569,13 +366,12 @@ async function getActualDataAndFillView(monat = currentDate.getMonth(), jahr = c
// Filter funktion aufrufen und entsprechend mit dem aktuellen Datum füllen // Filter funktion aufrufen und entsprechend mit dem aktuellen Datum füllen
const filteredEntries = await filterEntriesByMonthYear(monat === 12 ? 1 : monat + 1, jahr); const filteredEntries = await filterEntriesByMonthYear(monat === 12 ? 1 : monat + 1, jahr);
currentMonthEntries = filteredEntries
console.log("update-complete") console.log("update-complete")
console.log(jahr) console.log(jahr)
// Tabelle mit den aktuellen informationen füllen (funktion triggern und Daten übergeben) // Tabelle mit den aktuellen informationen füllen (funktion triggern und Daten übergeben)
fillTableWithEntries(filteredEntries, sortField, sortDirection, ldcLabel) fillTableWithEntries(filteredEntries, sortField, sortDirection)
// Ausgabe der Daten in den Log für DEV möglich // Ausgabe der Daten in den Log für DEV möglich
// console.log('Gefilterte Einträge für September 2024:', filteredEntries); // console.log('Gefilterte Einträge für September 2024:', filteredEntries);
@ -700,10 +496,6 @@ editsonstigesCheck.addEventListener('change', function() {
document.getElementById('editEntrySubmitButton').addEventListener('click', () => { document.getElementById('editEntrySubmitButton').addEventListener('click', () => {
if (!editallowToSend) {
return
}
var ToEditID = document.getElementById('editEntryID').value var ToEditID = document.getElementById('editEntryID').value
// console.log(`ID: ${ToEditID}, Hours: ${editHours.values}, Minutes: ${editMins.values}`) // console.log(`ID: ${ToEditID}, Hours: ${editHours.values}, Minutes: ${editMins.values}`)
@ -722,11 +514,10 @@ document.getElementById('editEntrySubmitButton').addEventListener('click', () =>
updateEntry(parseInt(ToEditID), 'hours', { updateEntry(parseInt(ToEditID), 'hours', {
date: document.getElementById('editDatum').value, date: document.getElementById('editDatum').value,
duration: ((parseInt(edithours.value) || 0)*60) + (parseInt(editMins.value) || 0), duration: ((parseInt(edithours.value) || 0)*60) + parseInt(editMins.value),
type: type, type: type,
status: 1, status: 1,
sondertext: document.getElementById('editsonstigesInput').value, sondertext: document.getElementById('editsonstigesInput').value,
partner: document.getElementById('editPartnerInput').value.trim(),
}).then(() => { }).then(() => {
// console.log('Eintrag aktualisiert.'); // console.log('Eintrag aktualisiert.');
location.reload() location.reload()
@ -761,10 +552,9 @@ document.getElementById('dashbord_month_area').addEventListener('click', () =>{
document.getElementById('dashbord_month_area').classList.replace('closeMonthArea', 'openMonthArea') document.getElementById('dashbord_month_area').classList.replace('closeMonthArea', 'openMonthArea')
document.getElementById('dashbord_month_area').classList.add(['openMonthArea']) document.getElementById('dashbord_month_area').classList.add(['openMonthArea'])
document.getElementById('dashbord_month_area').classList.add(['ActiveMonth']) document.getElementById('dashbord_month_area').classList.add(['ActiveMonth'])
// ActivitysTable ist jetzt flex:1 1 auto (siehe custom.css) und macht der wachsenden document.getElementById('ActivitysTable').classList.add(['shrinkActivitys'])
// Monatskarte automatisch Platz - kein manuelles shrinkActivitys/growActivitys mehr nötig, document.getElementById('ActivitysTable').classList.replace('growActivitys', 'shrinkActivitys')
// das würde der Flex-Berechnung sonst mit einer festen vh-Höhe in die Quere kommen. document.getElementById('dashbord_month_area').classList.remove('mt-4')
document.getElementById('dashbord_month_area').classList.remove('mt-3')
// document.getElementById('dashbord_year_area').classList.add('fade-out') // document.getElementById('dashbord_year_area').classList.add('fade-out')
// Für jedes ausgewählte Element die neue Klasse hinzufügen // Für jedes ausgewählte Element die neue Klasse hinzufügen
setTimeout(function() { setTimeout(function() {
@ -780,7 +570,7 @@ document.getElementById('dashbord_month_area').addEventListener('click', () =>{
} else { } else {
document.getElementById('dashbord_month_area').classList.remove('ActiveMonth') document.getElementById('dashbord_month_area').classList.remove('ActiveMonth')
document.getElementById('dashbord_month_area').classList.replace('openMonthArea', 'closeMonthArea') document.getElementById('dashbord_month_area').classList.replace('openMonthArea', 'closeMonthArea')
document.getElementById('dashbord_month_area').classList.add('mt-3') document.getElementById('ActivitysTable').classList.replace('shrinkActivitys', 'growActivitys')
monthEntrys.forEach(element => { monthEntrys.forEach(element => {
element.classList.replace('fade-in', 'fade-out'); element.classList.replace('fade-in', 'fade-out');
}); });
@ -844,19 +634,16 @@ function checkInput() {
allowToSend = true; allowToSend = true;
NewEntryButton.classList.remove('disabled'); NewEntryButton.classList.remove('disabled');
NewEntryButton.classList.replace('btn-light', 'btn-primary'); NewEntryButton.classList.replace('btn-light', 'btn-primary');
NewEntryButton.disabled = false;
} }
} else if (allowToSend) { } else if (allowToSend) {
allowToSend = false; allowToSend = false;
NewEntryButton.classList.add('disabled'); NewEntryButton.classList.add('disabled');
NewEntryButton.classList.replace('btn-primary', 'btn-light'); NewEntryButton.classList.replace('btn-primary', 'btn-light');
NewEntryButton.disabled = true;
} }
} else if (allowToSend) { } else if (allowToSend) {
allowToSend = false; allowToSend = false;
NewEntryButton.classList.add('disabled'); NewEntryButton.classList.add('disabled');
NewEntryButton.classList.replace('btn-primary', 'btn-light'); NewEntryButton.classList.replace('btn-primary', 'btn-light');
NewEntryButton.disabled = true;
} }
} }
@ -887,20 +674,17 @@ function checkEditInput() {
editallowToSend = true; editallowToSend = true;
EditSaveButton.classList.remove('disabled'); EditSaveButton.classList.remove('disabled');
EditSaveButton.classList.replace('btn-light', 'btn-primary'); EditSaveButton.classList.replace('btn-light', 'btn-primary');
EditSaveButton.disabled = false;
} }
} else if (editallowToSend) { } else if (editallowToSend) {
editallowToSend = false; editallowToSend = false;
EditSaveButton.classList.add('disabled'); EditSaveButton.classList.add('disabled');
EditSaveButton.classList.replace('btn-primary', 'btn-light'); EditSaveButton.classList.replace('btn-primary', 'btn-light');
EditSaveButton.disabled = true;
} }
} else if (editallowToSend) { } else if (editallowToSend) {
editallowToSend = false; editallowToSend = false;
EditSaveButton.classList.add('disabled'); EditSaveButton.classList.add('disabled');
EditSaveButton.classList.replace('btn-primary', 'btn-light'); EditSaveButton.classList.replace('btn-primary', 'btn-light');
EditSaveButton.disabled = true;
} }
// Debug ausgabe er Werte // Debug ausgabe er Werte
console.log(`Status: ${editallowToSend} - Check: ${editsonstigesCheck.checked} - Input: ${editsonstigesInput.value} - Stunden: ${edithours.value} - Min: ${editMins.value}`) console.log(`Status: ${editallowToSend} - Check: ${editsonstigesCheck.checked} - Input: ${editsonstigesInput.value} - Stunden: ${edithours.value} - Min: ${editMinuten.value}`)
} }

@ -5,7 +5,6 @@ function setAllOk() {
if (localStorage.getItem('UpdateInfoRead') === document.getElementById('versionnumber').value) { if (localStorage.getItem('UpdateInfoRead') === document.getElementById('versionnumber').value) {
document.getElementById('dashboard').classList.remove('d-none'); document.getElementById('dashboard').classList.remove('d-none');
document.getElementById('refresh_icon').classList.remove('d-none') document.getElementById('refresh_icon').classList.remove('d-none')
document.getElementById('share_icon').classList.remove('d-none')
document.getElementById('settings_icon').classList.remove('d-none') document.getElementById('settings_icon').classList.remove('d-none')
} }
} }

@ -6,37 +6,28 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'app/css/bootstrap.css' %}?v={{ STATIC_VERSION }}"> <link rel="stylesheet" href="{% static 'app/css/bootstrap.css' %}">
<link rel="stylesheet" href="{% static 'app/css/custom.css' %}?v={{ STATIC_VERSION }}"> <link rel="stylesheet" href="{% static 'app/css/custom.css' %}">
<script src="{% static 'app/js/dbcontrol.js' %}?v={{ STATIC_VERSION }}"></script> <script src="{% static 'app/js/dbcontrol.js' %}"></script>
<script src="{% static 'app/js/exportDB.js' %}?v={{ STATIC_VERSION }}"></script> <script src="{% static 'app/js/exportDB.js' %}"></script>
{% progressive_web_app_meta %} {% progressive_web_app_meta %}
<title>PioMint</title> <title>PioMint</title>
</head> </head>
<body id="body" style="overflow: hidden;" class="bg-white-color" data-app-version="{{ STATIC_VERSION }}"> <body id="body" style="overflow: hidden;" class="bg-white-color">
<!-- HEADER --> <!-- HEADER -->
<div class="container-fluid header-bg-block bg-white-color HeaderDefaultHeightbg" id="header-bg-block"> <div class="container-fluid header-bg-block bg-white-color HeaderDefaultHeightbg" id="header-bg-block">
<div class="container-fluid header-color-block text-center bg-white-color HeaderDefaultHeightcolor" id="header-color-block" style="color: white; width: 100vw;"> <div class="container-fluid header-color-block text-center bg-white-color HeaderDefaultHeightcolor" id="header-color-block" style="color: white; width: 100vw;">
<!-- Flexbox statt Bootstrap-Grid: 4 Icons (refresh, share, settings, settings-close) als <div class="row headerline justify-content-around">
col-3 hätten zusammen mit dem Titel die 12 Spalten gesprengt und wären umgebrochen - <span class="col-3 d-none" id="refresh_icon"><img onclick="window.location.reload();" src="{% static 'app/images/refresh.png' %}" alt="Settings" class="img-fluid" id="image" style="width: 40%; opacity: 0.75; padding-top: 0.65rem;"></span>
dadurch wurde der Settings-Button vom overflow:hidden des Headers abgeschnitten.
HeaderText ist absolut mittig zur vollen Breite positioniert, damit "PIOMINT" mittig
bleibt, egal wie breit die Icon-Gruppen links/rechts gerade sind. -->
<div class="d-flex align-items-center headerline" style="position: relative; width: 100%; height: 7vh; flex: 0 0 auto; padding: 0 0.75rem;">
<span class="d-none" id="refresh_icon" style="width: 28px; flex: 0 0 auto;"><img onclick="window.location.reload();" src="{% static 'app/images/refresh.png' %}" alt="Settings" class="img-fluid" id="image" style="width: 100%; opacity: 0.75;"></span>
<h3 id="HeaderText" style="display: none; margin: 0; padding: 0; line-height: 1; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);"><b>PIO</b><b style="color: rgb(120,170,86);">MINT</b></h3> <h3 id="HeaderText" class="pt-2" style="display: none;"><b>PIO</b><b style="color: rgb(120,170,86);">MINT</b></h3>
<div class="d-flex align-items-center" style="gap: 1rem; margin-left: auto;"> <!-- <div class="col-3"></div> -->
<span class="d-none" id="share_icon" style="width: 28px; flex: 0 0 auto;" title="Bericht für WhatsApp kopieren"> <span class="col-3 d-none" id="settings_icon"><img src="{% static 'app/images/settings.png' %}" alt="Settings" class="img-fluid" id="image" style="width: 40%; opacity: 0.75; padding-top: 0.65rem;"></span>
<img id="share_icon_img" src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgb(120,170,86)'><path d='M18,16.08c-0.76,0-1.44,0.3-1.96,0.77L8.91,12.7c0.05-0.23,0.09-0.46,0.09-0.7s-0.04-0.47-0.09-0.7l7.05-4.11c0.54,0.5,1.25,0.81,2.04,0.81c1.66,0,3-1.34,3-3c0-1.66-1.34-3-3-3s-3,1.34-3,3c0,0.24,0.04,0.47,0.09,0.7L8.04,9.81C7.5,9.31,6.79,9,6,9c-1.66,0-3,1.34-3,3s1.34,3,3,3c0.79,0,1.5-0.31,2.04-0.81l7.12,4.16c-0.05,0.21-0.08,0.43-0.08,0.65c0,1.61,1.31,2.92,2.92,2.92c1.61,0,2.92-1.31,2.92-2.92C20.92,17.39,19.61,16.08,18,16.08z'/></svg>" alt="Teilen" class="img-fluid" style="width: 100%; opacity: 0.9;"> <span class="col-3 d-none" id="settings_close_icon"><img src="{% static 'app/images/close.png' %}" alt="SettingsClose" class="img-fluid" id="image" style="width: 40%; opacity: 0.75; padding-top: 0.65rem;"></span>
</span>
<span class="d-none" id="settings_icon" style="width: 28px; flex: 0 0 auto;"><img src="{% static 'app/images/settings.png' %}" alt="Settings" class="img-fluid" id="image" style="width: 100%; opacity: 0.75;"></span>
<span class="d-none" id="settings_close_icon" style="width: 28px; flex: 0 0 auto;"><img src="{% static 'app/images/close.png' %}" alt="SettingsClose" class="img-fluid" id="image" style="width: 100%; opacity: 0.75;"></span>
</div>
</div> </div>
<div class="settings text-left"> <div class="settings text-left">
@ -57,20 +48,11 @@
55h-Regel (Jahr): 55h-Regel (Jahr):
<input type="checkbox" name="rule55hCheck" id="rule55hCheck" style="position: static; width: 1.5rem; height: 1.5rem; margin: 0;"> <input type="checkbox" name="rule55hCheck" id="rule55hCheck" style="position: static; width: 1.5rem; height: 1.5rem; margin: 0;">
</li> </li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Schnellwahl-Titel:
<input type="text" class="w-25 btn btn-secondary" name="ldcNameInput" id="ldcNameInput" value="LDC" maxlength="20" onfocus="document.getElementById('SETLdcNameSaveButton').classList.remove('d-none')">
<button class="btn d-none" id="SETLdcNameSaveButton">Sichern</button>
</li>
<h5 class="mt-5 text-white text-center">Kontakt</h5> <h5 class="mt-5 text-white text-center">Kontakt</h5>
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
WhatsApp Community WhatsApp Community
<a class="btn btn-success" href="https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ" target="_blank" rel="noopener noreferrer">Beitreten</a> <a class="btn btn-success" href="https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ" target="_blank" rel="noopener noreferrer">Beitreten</a>
</li> </li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Feedback / Fehler melden
<a class="btn btn-primary" href="mailto:app-feedback@zielke.studio?subject=PioMint%20Feedback">E-Mail</a>
</li>
<h5 class="mt-5 text-white text-center">Daten Verwaltung</h5> <h5 class="mt-5 text-white text-center">Daten Verwaltung</h5>
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<button class="btn btn-info" onclick="exportIndexedDBData()">Exportieren</button> <button class="btn btn-info" onclick="exportIndexedDBData()">Exportieren</button>
@ -89,6 +71,6 @@
{% endblock %} {% endblock %}
</div> </div>
<script src="{% static 'app/js/importDB.js' %}?v={{ STATIC_VERSION }}"></script> <script src="{% static 'app/js/importDB.js' %}"></script>
</body> </body>
</html> </html>

@ -1,249 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'app/css/bootstrap.css' %}?v={{ STATIC_VERSION }}">
<title>PioMint – Nutzung</title>
<script>
// Theme so früh wie möglich setzen - sonst blitzt beim Laden kurz
// das helle Theme auf, bevor auf Dunkel umgeschaltet wird.
(function () {
try {
var saved = localStorage.getItem("piomintDashboardTheme");
var theme = saved || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
} catch (e) {}
})();
</script>
<style>
:root {
--bg: #f4f6f5; --card-bg: #ffffff; --ink: #212529; --ink-muted: #6c757d;
--border: #dee2e6; --stripe: rgba(0, 0, 0, .05); --accent: rgb(120, 170, 86);
--shadow: 0 0 0.5rem gray;
}
:root[data-theme="dark"] {
--bg: #15171a; --card-bg: #1f2124; --ink: #e8e8e6; --ink-muted: #9a9d9f;
--border: rgba(255, 255, 255, .12); --stripe: rgba(255, 255, 255, .04); --accent: rgb(140, 195, 100);
--shadow: 0 0 0.6rem rgba(0, 0, 0, .5);
}
body {
background-color: var(--bg);
color: var(--ink);
padding: 2rem 1rem 3rem 1rem;
}
h1 b {
color: var(--accent);
}
.theme-toggle {
width: 2.25rem; height: 2.25rem; border-radius: 999px; border: 1px solid var(--border);
background: var(--card-bg); display: inline-flex; align-items: center; justify-content: center;
font-size: 1rem; cursor: pointer; margin-left: .5rem;
}
.stat-card {
background: var(--card-bg);
border-radius: 1rem;
box-shadow: var(--shadow);
padding: 1.25rem;
}
.stat-card.text-center .stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--accent);
}
.stat-card .stat-label {
color: var(--ink-muted);
font-size: 0.9rem;
}
.stat-card table {
background: var(--card-bg);
color: var(--ink);
}
[data-theme="dark"] .text-muted {
color: var(--ink-muted) !important;
}
[data-theme="dark"] .table-striped tbody tr:nth-of-type(odd) {
background-color: var(--stripe);
}
[data-theme="dark"] .table td,
[data-theme="dark"] .table th,
[data-theme="dark"] .border-bottom {
border-color: var(--border) !important;
}
[data-theme="dark"] code {
color: #f092c0;
}
</style>
</head>
<body>
<div class="container" style="max-width: 960px;">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="m-0">PIO<b>MINT</b> – Nutzung</h1>
<div>
<a href="/app/home/" class="btn btn-sm btn-light">Zur App</a>
<button type="button" class="theme-toggle" aria-label="Hell/Dunkel umschalten" title="Hell/Dunkel umschalten">🌙</button>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-md-6">
<div class="stat-card text-center">
<div class="stat-value">{{ total_connections }}</div>
<div class="stat-label">Verbindungen insgesamt <small class="text-muted">(alle Pings)</small></div>
</div>
</div>
<div class="col-6 col-md-6">
<div class="stat-card text-center">
<div class="stat-value">{{ users_total }}</div>
<div class="stat-label">Nutzer insgesamt <small class="text-muted">(als App installiert)</small></div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-md-4">
<div class="stat-card text-center">
<div class="stat-value">{{ users_today }}</div>
<div class="stat-label">Nutzer heute</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="stat-card text-center">
<div class="stat-value">{{ users_7d }}</div>
<div class="stat-label">Nutzer (7 Tage)</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="stat-card text-center">
<div class="stat-value">{{ users_30d }}</div>
<div class="stat-label">Nutzer (30 Tage)</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="stat-card">
<h5>Plattform <small class="text-muted">(eindeutige Nutzer)</small></h5>
<ul class="list-unstyled mb-0">
{% for row in by_platform %}
<li class="d-flex justify-content-between border-bottom py-1">
<span>{{ row.platform|default:"Unbekannt" }}</span><b>{{ row.count }}</b>
</li>
{% empty %}
<li class="text-muted">Noch keine Daten</li>
{% endfor %}
</ul>
</div>
</div>
<div class="col-md-6">
<div class="stat-card">
<h5>Browser <small class="text-muted">(eindeutige Nutzer)</small></h5>
<ul class="list-unstyled mb-0">
{% for row in by_browser %}
<li class="d-flex justify-content-between border-bottom py-1">
<span>{{ row.browser|default:"Unbekannt" }}</span><b>{{ row.count }}</b>
</li>
{% empty %}
<li class="text-muted">Noch keine Daten</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="stat-card mb-3">
<h5 class="mb-1">Nutzer vs. Aufrufer</h5>
<p class="mb-0 text-muted">
<b>{{ users_total }} Nutzer</b> haben PioMint mindestens einmal als installierte PWA
(Standalone) geöffnet – das ist echte Nutzung. Weitere
<b>{{ visitors_total }} Aufrufer</b> haben die Seite nur im Browser-Tab geöffnet,
aber nie installiert; sie fließen oben nicht in die Nutzer-Zahlen ein.
</p>
</div>
<div class="stat-card mb-3">
<h5 class="mb-2">Geräte-Übersicht <small class="text-muted">(ein Eintrag pro Gerät, nicht pro Ping)</small></h5>
<div style="overflow-x: auto;">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Gerät</th>
<th>Pings</th>
<th>Erster Kontakt</th>
<th>Letzter Kontakt</th>
<th>Nutzer?</th>
</tr>
</thead>
<tbody>
{% for device in device_summary %}
<tr>
<td><code>{{ device.device_id|slice:":8" }}…</code></td>
<td>{{ device.ping_count }}</td>
<td>{{ device.first_seen|date:"d.m.Y H:i" }}</td>
<td>{{ device.last_seen|date:"d.m.Y H:i" }}</td>
<td>{{ device.standalone_any|yesno:"Ja,Nein" }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="text-muted">Noch keine Daten</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="stat-card">
<h5 class="mb-2">Letzte Signale <small class="text-muted">(roher Ping-Log, ein Gerät kann hier mehrfach auftauchen)</small></h5>
<div style="overflow-x: auto;">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Zeit</th>
<th>Gerät</th>
<th>Version</th>
<th>Plattform</th>
<th>Browser</th>
<th>Standalone</th>
</tr>
</thead>
<tbody>
{% for event in recent_events %}
<tr>
<td>{{ event.created_at|date:"d.m.Y H:i" }}</td>
<td><code>{{ event.device_id|slice:":8" }}…</code></td>
<td>{{ event.app_version|default:"–" }}</td>
<td>{{ event.platform }}</td>
<td>{{ event.browser }}</td>
<td>{{ event.is_standalone|yesno:"Ja,Nein" }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-muted">Noch keine Daten</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-muted mt-4" style="font-size: 0.8rem;">
Komplett anonym: <code>device_id</code> ist eine zufällige, nur lokal im Browser
gespeicherte ID ohne Bezug zu einer Person. Ein Signal pro Gerät und Tag.
</p>
</div>
<script>
(function () {
var button = document.querySelector(".theme-toggle");
function paint() {
button.textContent = document.documentElement.getAttribute("data-theme") === "dark" ? "☀️" : "🌙";
}
paint();
button.addEventListener("click", function () {
var next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try { localStorage.setItem("piomintDashboardTheme", next); } catch (e) {}
paint();
});
})();
</script>
</body>
</html>

@ -10,11 +10,11 @@
document.getElementById('maincontainer').classList.replace('align-items-center', 'align-items-top') document.getElementById('maincontainer').classList.replace('align-items-center', 'align-items-top')
</script> </script>
<div class="container" id="welcome_content" style="padding-bottom: 3rem;"> <div class="container" id="welcome_content">
<br> <br>
<h1>Willkommen! 👋</h1> <h1>Wilkommen!</h1>
<p> <p>
Schön, dass du hier bist! Schön das du hier bist!
<br>Ich wünsche dir viel Freude mit der App. <br>Ich wünsche dir viel Freude mit der App.
<br>Diese ist noch ganz neu und wächst immer weiter! <br>Diese ist noch ganz neu und wächst immer weiter!
<br> <br>
@ -34,7 +34,7 @@
<button class="btn btn-primary" id="allOk">Alles klar</button> <button class="btn btn-primary" id="allOk">Alles klar</button>
</div> </div>
<div class="container mt-5" id="update_content" style="padding-bottom: 3rem;"> <div class="container mt-5" id="update_content">
{% include 'app/updateinfo.html' %} {% include 'app/updateinfo.html' %}
@ -42,40 +42,35 @@
</div> </div>
<div class="d-none" id="dashboard"> <div class="d-none" id="dashboard">
<div class="row justify-content-around mt-3" style="width: 100vw; color: black; flex-shrink: 0;"> <div class="row justify-content-around mt-3" style="width: 100vw; color: black;">
<div class="col-11" id="dashbord_year_area" style="padding: 0.5rem 1rem; border-radius:1rem; box-shadow: 0 0 0.5rem gray; display: flex; flex-direction: column; justify-content: center; gap: 0.35rem;"> <div class="col-11" id="dashbord_year_area" style="height: 5vh; padding: 1rem; border-radius:1rem; box-shadow: 0 0 0.5rem gray">
<!-- flex-shrink:0 auf beiden Kindern: sonst quetscht Flexbox in der knappen Höhe <div class="row justify-content-between">
den 5px-Balken dünner als den (nicht-flex) Balken der Monatskarte. --> <p class="col-6" style="font-size: 1rem; margin-top: -0.75rem; white-space: nowrap;"><b>Jahr:</b> <span id="yearHours"></span>h/600h<span id="rule55Badge" class="badge badge-warning d-none" title="55h-Regel aktiv: Monate mit LDC/Sonstiges werden auf 55h gedeckelt" style="font-size: 0.55rem; vertical-align: middle; margin-left: 0.25rem;">55h</span></p>
<div class="row justify-content-between" style="margin: 0; flex-shrink: 0;"> <p class="offset-1 col-5" style="font-size: 1rem; margin-top: -0.75rem;"><b>Offen:</b> <span id="yearRest"></span>h</p>
<p class="col-6" style="font-size: 1rem; margin: 0; white-space: nowrap;"><b>Jahr:</b> <span id="yearHours"></span>h/600h<span id="rule55Badge" class="badge badge-warning d-none" title="55h-Regel aktiv: Monate mit LDC/Sonstiges werden auf 55h gedeckelt" style="font-size: 0.55rem; vertical-align: middle; margin-left: 0.25rem;">55h</span></p> </div>
<p class="offset-1 col-5" style="font-size: 1rem; margin: 0;"><b>Offen:</b> <span id="yearRest"></span>h</p> <div class="progress" style="height: 5px; width: 100%; border-radius: 2rem; background-color: lightgray; margin-top: -1rem;">
</div>
<div class="progress" style="height: 5px; width: 100%; border-radius: 2rem; background-color: lightgray; margin: 0; flex-shrink: 0;">
<div class="progress-bar" role="progressbar" style="background-color:rgb(120,170,86); width: 0.5%; height: 5px;" aria-valuenow="10" aria-valuemin="0" aria-valuemax="50" id="prBarYear"></div> <div class="progress-bar" role="progressbar" style="background-color:rgb(120,170,86); width: 0.5%; height: 5px;" aria-valuenow="10" aria-valuemin="0" aria-valuemax="50" id="prBarYear"></div>
</div> </div>
</div> </div>
<div class="col-11 mt-3" id="dashbord_month_area" style="padding: 0.75rem 1rem; border-radius:1rem; box-shadow: 0 0 0.5rem gray"> <div class="col-11 mt-4" id="dashbord_month_area" style="padding: 1rem; border-radius:1rem; box-shadow: 0 0 0.5rem gray">
<div class="row justify-content-between" style="margin: 0 0 0.4rem 0;"> <div class="row justify-content-between">
<p class="col-7" style="font-size: 1.25rem; margin: 0;"><b id="MonthNameOfActual">Monat:</b> <span id="monthHours"></span>h</p> <p class="col-7" style="font-size: 1.25rem;"><b id="MonthNameOfActual">Monat:</b> <span id="monthHours"></span>h</p>
<input type="number" class="d-none" id="MonthSumHours"> <input type="number" class="d-none" id="MonthSumHours">
<p class="col-5" style="font-size: 1.25rem; margin: 0;"><b>Offen:</b> <span id="monthRest"></span>h</p> <p class="col-5" style="font-size: 1.25rem;"><b>Offen:</b> <span id="monthRest"></span>h</p>
</div> </div>
<div class="progress" style="height: 5px; width: 100%; border-radius: 2rem; background-color: lightgray; margin: 0;"> <div class="progress" style="height: 5px; width: 100%; border-radius: 2rem; background-color: lightgray; margin-top: -0.75rem">
<div class="progress-bar" role="progressbar" style="background-color:rgb(120,170,86); height: 5px; width: 1%;" aria-valuenow="10" aria-valuemin="0" aria-valuemax="50" id="prBarMonth"></div> <div class="progress-bar" role="progressbar" style="background-color:rgb(120,170,86); height: 5px; width: 1%;" aria-valuenow="10" aria-valuemin="0" aria-valuemax="50" id="prBarMonth"></div>
</div> </div>
<!-- justify-content-around verteilt die Badges wie bisher auf die verfügbare Breite, <div class="row justify-content-around">
solange sie passen; erst wenn der individuell vergebene Schnellwahl-Titel (siehe <button class="col-3 mt-3 badge badge-primary" style="height: 25px; border: none;">Dienst: <span id="monthHourService"></span>h</button>
Einstellungen) zu lang wird, greift overflow-x: auto statt eines Umbruchs. --> <button class="col-3 mt-3 badge badge-primary" style="height: 25px; border: none;">LDC: <span id="monthHourLDC"></span>h</button>
<div class="d-flex justify-content-around" style="overflow-x: auto; gap: 0.5rem; padding-bottom: 2px; margin-top: 0.5rem;"> <button class="col-3 mt-3 badge badge-primary" style="height: 25px; border: none;">Übrig: <span id="monthHourOther"></span>h</button>
<button class="badge badge-primary" style="height: 25px; border: none; white-space: nowrap; flex: 0 0 auto;">Dienst: <span id="monthHourService"></span>h</button>
<button class="badge badge-primary" style="height: 25px; border: none; white-space: nowrap; flex: 0 0 auto;"><span id="monthHourLDCLabel">LDC</span>: <span id="monthHourLDC"></span>h</button>
<button class="badge badge-primary" style="height: 25px; border: none; white-space: nowrap; flex: 0 0 auto;">Übrig: <span id="monthHourOther"></span>h</button>
</div> </div>
<div class="row justify-content-around align-middle monthEntrys" style="max-height: 2.5rem; width: 99%; margin-left: 1%; margin-top: 0.5rem;"> <div class="row justify-content-around align-middle monthEntrys mt-3" style="max-height: 2.5rem; width: 99%; margin-left: 1%;">
<span class="col-2 btn btn-secondary" id="btn-month-before" onclick="event.stopPropagation();"><</span> <span class="col-2 btn btn-secondary" id="btn-month-before" onclick="event.stopPropagation();"><</span>
<p class="col-6" style="text-align:center; width: 100%; font-size: 1.75rem; margin: 0;"><b id="actualMonthName"></b></p> <p class="col-6" style="text-align:center; width: 100%; font-size: 1.75rem; margin: 0;"><b id="actualMonthName"></b></p>
<span class="col-2 btn btn-secondary" id="btn-month-next" onclick="event.stopPropagation();">></span> <span class="col-2 btn btn-secondary" id="btn-month-next" onclick="event.stopPropagation();">></span>
@ -83,10 +78,10 @@
</div> </div>
</div> </div>
<div class="row justify-content-around" id="timerCard" style="width: 100vw; color: black; padding-top: 1.5rem; flex-shrink: 0;"> <div class="row justify-content-around" id="timerCard" style="width: 100vw; color: black; padding-top: 2rem;">
<div class="col-11" style="padding: 0.75rem 1rem; border-radius:1rem; box-shadow: 0 0 0.5rem gray; display: flex; flex-direction: column; justify-content: center; gap: 0.35rem;"> <div class="col-11" style="height: 15vh; padding: 1rem; border-radius:1rem; box-shadow: 0 0 0.5rem gray">
<p style="font-size: 1.2rem; margin: 0; flex-shrink: 0;"><b>Timer</b></p> <p style="font-size: 1.2rem;"><b>Timer</b></p>
<div class="row justify-content-between" style="margin: 0; flex-shrink: 0;"> <div class="row justify-content-between" style="margin-top: -1rem;">
<p class="col-5"><b id="timerDisplay" style="font-size: 2.5rem;">00:00</b><i id="secondsDisplay" style="font-size: 1rem;">:00</i></p> <p class="col-5"><b id="timerDisplay" style="font-size: 2.5rem;">00:00</b><i id="secondsDisplay" style="font-size: 1rem;">:00</i></p>
<div class="col-7 row justify-content-center"> <div class="col-7 row justify-content-center">
<img id="startTimer" src="{% static 'app/images/play.png' %}" alt="play" class="img-fluid col-6" style="height: 55px;"> <img id="startTimer" src="{% static 'app/images/play.png' %}" alt="play" class="img-fluid col-6" style="height: 55px;">
@ -96,17 +91,18 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 mt-3" id="ActivitysTable" style="flex: 1 1 auto; min-height: 220px; padding: 0rem 1rem 1rem 1rem; border-radius:1rem; box-shadow: 0 0 0.5rem gray; overflow-y: auto;"> <div class="row justify-content-around" style="width: 95vw; color: black; padding-top: 2rem;"></div>
<div style="position: sticky; top: 0; background-color: white; padding-top: 1rem; padding-bottom: 0.5rem; z-index: 100; width: 100%;"> <div class="col-12" id="ActivitysTable" style="height: 40vh; padding: 0rem 1rem 1rem 1rem; border-radius:1rem; box-shadow: 0 0 0.5rem gray; overflow-y: auto;">
<div style="position: sticky; top: 0; background-color: white; padding-top: 1rem; z-index: 100; width: 100%;">
<div class="d-flex justify-content-between align-items-center" style="width: 100%;"> <div class="d-flex justify-content-between align-items-center" style="width: 100%;">
<p style="font-size: 1.2rem; margin: 0;" ><b>Aktivitäten</b></p> <p style="font-size: 1.2rem; margin: 0;" ><b>Aktivitäten</b></p>
<div class="d-flex align-items-center" style="gap: 1rem;"> <div class="d-flex align-items-center" style="gap: 0.5rem;">
<button type="button" id="openNewEntryForm" class="btn btn-light" data-bs-toggle="modal" data-bs-target="#newEntryModal" style="width: 28px; height: 28px; padding: 0;"><img id="openNewEntryFormIcon" src="{% static 'app/images/addcircle.png' %}" alt="add" class="img-fluid" style="width: 100%;"></button> <button type="button" id="openNewEntryForm" class="btn btn-light" data-bs-toggle="modal" data-bs-target="#newEntryModal" style="width: 45px; height: 45px; padding: 0;"><img id="openNewEntryFormIcon" src="{% static 'app/images/addcircle.png' %}" alt="add" class="img-fluid"></button>
<button type="button" id="toggleSortOptions" class="btn btn-light" title="Filter/Sortierung ein-/ausblenden" style="width: 28px; height: 28px; padding: 0;"><img id="toggleSortOptionsIcon" src="{% static 'app/images/more.png' %}" alt="filter" class="img-fluid" style="width: 100%;"></button> <button type="button" id="toggleSortOptions" class="btn btn-light" title="Filter/Sortierung ein-/ausblenden" style="width: 45px; height: 45px; padding: 0;"><img id="toggleSortOptionsIcon" src="{% static 'app/images/more.png' %}" alt="filter" class="img-fluid"></button>
<button type="button" id="openViewOfEntry" class="btn btn-light" title="Tabelle maximieren" style="width: 28px; height: 28px; padding: 0;"><img id="openViewOfEntryIcon" src="{% static 'app/images/open.png' %}" alt="open" class="img-fluid" style="width: 100%;"></button> <button type="button" id="openViewOfEntry" class="btn btn-light" title="Tabelle maximieren" style="width: 45px; height: 45px; padding: 0;"><img id="openViewOfEntryIcon" src="{% static 'app/images/open.png' %}" alt="open" class="img-fluid"></button>
</div> </div>
</div> </div>
<div class="d-flex align-items-center sortOptionsCollapsed" id="sortOptionsRow" style="width: 100%; margin-top: 0.5rem; gap: 0.5rem;"> <div class="d-flex align-items-center" id="sortOptionsRow" style="width: 100%; margin-top: 0.5rem; gap: 0.5rem;">
<!-- Eigener Dropdown statt <select>, damit das Auswahlmenü nicht als winziges natives <!-- Eigener Dropdown statt <select>, damit das Auswahlmenü nicht als winziges natives
Popup irgendwo auf dem Bildschirm erscheint, sondern direkt unter dem Button. --> Popup irgendwo auf dem Bildschirm erscheint, sondern direkt unter dem Button. -->
<div style="position: relative; flex: 1;"> <div style="position: relative; flex: 1;">
@ -191,19 +187,13 @@
<input type="date" class="form-control" id="datum" required> <input type="date" class="form-control" id="datum" required>
</div> </div>
<!-- Partner (optional) -->
<div class="form-group">
<p><b>Partner</b> <span class="text-muted" style="font-weight: normal; font-size: 0.85rem;">(optional)</span></p>
<input type="text" class="form-control" id="partnerInput" placeholder="Name" maxlength="30">
</div>
<!-- Auswahl LDC oder Sonstiges (Checkboxen) --> <!-- Auswahl LDC oder Sonstiges (Checkboxen) -->
<div class="form-group"> <div class="form-group">
<p><b>Optionen:</b></p> <p><b>Optionen:</b></p>
<div> <div>
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="ldcCheck" name="optionen" value="LDC"> <input class="form-check-input" type="checkbox" id="ldcCheck" name="optionen" value="LDC">
<label class="form-check-label" for="ldcCheck" id="ldcCheckLabel">LDC</label> <label class="form-check-label" for="ldcCheck">LDC</label>
</div> </div>
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="sonstigesCheck" name="optionen" value="Sonstiges"> <input class="form-check-input" type="checkbox" id="sonstigesCheck" name="optionen" value="Sonstiges">
@ -264,19 +254,13 @@
<input type="date" class="form-control editItem" id="editDatum" required> <input type="date" class="form-control editItem" id="editDatum" required>
</div> </div>
<!-- Partner (optional) -->
<div class="form-group">
<p><b>Partner</b> <span class="text-muted" style="font-weight: normal; font-size: 0.85rem;">(optional)</span></p>
<input type="text" class="form-control editItem" id="editPartnerInput" placeholder="Name" maxlength="30">
</div>
<!-- Auswahl LDC oder Sonstiges (Checkboxen) --> <!-- Auswahl LDC oder Sonstiges (Checkboxen) -->
<div class="form-group"> <div class="form-group">
<p><b>Optionen:</b></p> <p><b>Optionen:</b></p>
<div> <div>
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
<input class="form-check-input editItem" type="checkbox" id="editldcCheck" name="editldcCheck" value="LDC"> <input class="form-check-input editItem" type="checkbox" id="editldcCheck" name="editldcCheck" value="LDC">
<label class="form-check-label" for="editldcCheck" id="editldcCheckLabel">LDC</label> <label class="form-check-label" for="editldcCheck">LDC</label>
</div> </div>
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
<input class="form-check-input editItem" type="checkbox" id="editsonstigesCheck" name="editsonstigesCheck" value="Sonstiges"> <input class="form-check-input editItem" type="checkbox" id="editsonstigesCheck" name="editsonstigesCheck" value="Sonstiges">
@ -300,22 +284,6 @@
</div> </div>
</div> </div>
<!-- BERICHT TEILEN: Vorschau des kopierten Textes, auch als reine Übersicht ohne Kopieren nutzbar -->
<div class="modal fade" id="shareReportModal" tabindex="-1" aria-labelledby="shareReportModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="shareReportModalLabel">Bericht</h5>
<button type="button" class="btn btn-sm btn-danger" data-bs-dismiss="modal" aria-label="Schließen">X</button>
</div>
<div class="modal-body">
<p id="shareReportStatus" class="mb-2" style="font-weight: bold;"></p>
<pre id="shareReportPreview" style="white-space: pre-wrap; font-family: inherit; margin: 0; background-color: #f8f9fa; padding: 0.75rem; border-radius: 0.5rem;"></pre>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
@ -332,7 +300,6 @@
document.getElementById('header-color-block').classList.replace('HeaderDefaultHeightcolor', 'openSettingscolor') document.getElementById('header-color-block').classList.replace('HeaderDefaultHeightcolor', 'openSettingscolor')
document.getElementById('header-color-block').classList.replace('closeSettingscolor', 'openSettingscolor') document.getElementById('header-color-block').classList.replace('closeSettingscolor', 'openSettingscolor')
document.getElementById('refresh_icon').style.opacity = 0; document.getElementById('refresh_icon').style.opacity = 0;
document.getElementById('share_icon').style.opacity = 0;
document.getElementById('settings_icon').classList.add('d-none'); document.getElementById('settings_icon').classList.add('d-none');
document.getElementById('settings_close_icon').classList.remove('d-none'); document.getElementById('settings_close_icon').classList.remove('d-none');
}) })
@ -342,12 +309,11 @@
document.getElementById('settings_icon').classList.remove('d-none'); document.getElementById('settings_icon').classList.remove('d-none');
document.getElementById('settings_close_icon').classList.add('d-none'); document.getElementById('settings_close_icon').classList.add('d-none');
document.getElementById('refresh_icon').style.opacity = 1; document.getElementById('refresh_icon').style.opacity = 1;
document.getElementById('share_icon').style.opacity = 1;
}) })
</script> </script>
<script src="{% static 'app/js/home01.js' %}?v={{ STATIC_VERSION }}"></script> <script src="{% static 'app/js/home01.js' %}"></script>
<script src="{% static 'app/js/timer.js' %}?v={{ STATIC_VERSION }}"></script> <script src="{% static 'app/js/timer.js' %}"></script>
<script src="{% static 'app/js/welcomeControl.js' %}?v={{ STATIC_VERSION }}"></script> <script src="{% static 'app/js/welcomeControl.js' %}"></script>
<script src="{% static 'app/js/UpdateInfoControl.js' %}?v={{ STATIC_VERSION }}"></script> <script src="{% static 'app/js/UpdateInfoControl.js' %}"></script>
{% endblock %} {% endblock %}

@ -1,424 +1,15 @@
{% load pwa %} {% extends 'app/base.html' %}
{% load static %}
<!DOCTYPE html> {% block content %}
<html lang="de"> <h1>Installieren</h1>
<head> <br>
<meta charset="UTF-8"> <p>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> Folgende Schritte sind nötig um PioMint unter IOS zu installieren:
<link rel="stylesheet" href="{% static 'app/css/bootstrap.css' %}?v={{ STATIC_VERSION }}"> <br>
{% comment %} 1. Auf das <b>Teilen</b> Symbol klicken.
Manifest + Apple-Meta-Tags: ohne die legt "Zum Home-Bildschirm"/"App installieren" <br>
nur ein Lesezeichen auf /install/ an statt die PWA mit start_url zu installieren 2. Auf <b>Zum Home-Bildschirm hinzufügen</b> klicken.
{% endcomment %} <br>
{% progressive_web_app_meta %} 3. Auf <b>Hinzufügen</b> klicken.
</p>
<title>PioMint installieren</title> {% endblock %}
<style>
:root {
--mint: rgb(120, 170, 86);
--mint-dark: rgb(98, 140, 68);
--teal: #026670;
--ink: #1c2723;
--paper: #fbfbf9;
}
html, body {
overflow-x: hidden;
}
body {
background-color: var(--paper);
color: var(--ink);
padding-bottom: 4rem;
}
.hero {
background: linear-gradient(155deg, var(--teal) 0%, #024a52 100%);
color: white;
padding: 3rem 1.25rem 4.5rem 1.25rem;
text-align: center;
}
.hero img {
width: 84px;
height: 84px;
border-radius: 22px;
box-shadow: 0 0.75rem 1.75rem rgba(0, 0, 0, 0.35);
margin-bottom: 1.25rem;
}
.hero h1 {
font-weight: 700;
font-size: clamp(1.4rem, 7vw, 2.5rem);
margin-bottom: 0.5rem;
}
.hero h1 b {
color: var(--mint);
}
.hero p {
color: rgba(255, 255, 255, 0.8);
max-width: 32rem;
margin: 0 auto;
}
.wrap {
max-width: 640px;
margin: -3rem auto 0 auto;
padding: 0 1.25rem;
}
/* Segmented control */
.platform-switch {
display: flex;
background: white;
border-radius: 1rem;
box-shadow: 0 0.5rem 1.5rem rgba(2, 30, 33, 0.15);
padding: 0.35rem;
gap: 0.35rem;
}
.platform-switch button {
flex: 1;
min-width: 0;
border: none;
background: transparent;
padding: 0.7rem 0.5rem;
border-radius: 0.7rem;
font-weight: 600;
font-size: 0.95rem;
color: #6c757d;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: background-color 0.2s ease, color 0.2s ease;
}
.platform-switch button.active {
background: var(--teal);
color: white;
}
.platform-switch svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* Warnhinweis */
.browser-note {
margin-top: 1.25rem;
background: #fff7e6;
border: 1px solid #f3dca6;
color: #7a5b12;
border-radius: 0.85rem;
padding: 0.9rem 1.1rem;
font-size: 0.9rem;
display: flex;
gap: 0.65rem;
align-items: flex-start;
}
.browser-note svg {
width: 20px;
height: 20px;
flex-shrink: 0;
margin-top: 0.1rem;
}
/* Schritte */
.steps {
margin-top: 2rem;
position: relative;
}
.step {
display: flex;
gap: 1.1rem;
padding-bottom: 2rem;
position: relative;
}
.step:not(:last-child)::before {
content: "";
position: absolute;
left: 27px;
top: 56px;
bottom: 0;
width: 2px;
background: repeating-linear-gradient(to bottom, #d8ded4 0 6px, transparent 6px 12px);
}
.step-badge {
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: 16px;
background: white;
box-shadow: 0 0.4rem 1rem rgba(2, 30, 33, 0.12);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.step-badge svg {
width: 26px;
height: 26px;
color: var(--teal);
}
.step-num {
position: absolute;
top: -6px;
right: -6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--mint);
color: white;
font-size: 0.7rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
/* Tippen-Indikator */
.tap-ring {
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--mint);
animation: tapPulse 1.8s ease-out infinite;
}
@keyframes tapPulse {
0% { transform: scale(0.6); opacity: 0.9; }
80% { transform: scale(1.8); opacity: 0; }
100% { transform: scale(1.8); opacity: 0; }
}
.step-body {
padding-top: 0.35rem;
min-width: 0;
}
.browser-note > div {
min-width: 0;
}
.step-body h3 {
font-size: 1.05rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.step-body p {
color: #4b5a54;
margin: 0;
font-size: 0.95rem;
}
.step-body kbd {
background: #eef2ef;
color: var(--teal);
border-radius: 0.35rem;
padding: 0.05rem 0.4rem;
font-weight: 600;
font-size: 0.85em;
}
.done-card {
margin-top: 0.5rem;
background: linear-gradient(155deg, var(--mint) 0%, var(--mint-dark) 100%);
color: white;
border-radius: 1rem;
padding: 1.5rem;
text-align: center;
box-shadow: 0 0.5rem 1.5rem rgba(98, 140, 68, 0.3);
}
.done-card h3 {
font-weight: 700;
margin-bottom: 0.35rem;
}
.done-card p {
margin: 0;
opacity: 0.92;
}
footer {
text-align: center;
margin-top: 3rem;
color: #8a938c;
font-size: 0.85rem;
}
footer a {
color: var(--teal);
font-weight: 600;
text-decoration: none;
}
[data-platform-section] {
display: none;
}
[data-platform-section].active {
display: block;
}
@media (prefers-reduced-motion: reduce) {
.tap-ring { animation: none; opacity: 0.6; }
}
</style>
</head>
<body>
<div class="hero">
<img src="{% static 'app/images/appleicon_whitebg.png' %}" alt="PioMint Icon">
<h1>PIO<b>MINT</b> installieren</h1>
<p>Einmal einrichten, dann liegt PioMint als eigenes App-Icon auf deinem
Homescreen – ganz ohne App Store, in unter einer Minute.</p>
</div>
<div class="wrap">
<div class="platform-switch" role="tablist">
<button type="button" id="tab-ios" role="tab" onclick="showPlatform('ios')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.53 4.09l-.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>
iPhone
</button>
<button type="button" id="tab-android" role="tab" onclick="showPlatform('android')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.6 9.48l1.84-3.18a.5.5 0 00-.87-.5l-1.86 3.22a10.9 10.9 0 00-9.42 0L5.43 5.8a.5.5 0 00-.87.5L6.4 9.48A9.99 9.99 0 001 17h22a9.99 9.99 0 00-5.4-7.52zM7 14.5a1 1 0 111-1 1 1 0 01-1 1zm10 0a1 1 0 111-1 1 1 0 01-1 1z"/></svg>
Android
</button>
</div>
<!-- iOS -->
<div data-platform-section="ios">
<div class="browser-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01" stroke-linecap="round"/></svg>
<div>Funktioniert nur in <b>Safari</b>. Falls dieser Link in Instagram, WhatsApp
oder einer anderen App geöffnet wurde: oben rechts <b>„In Safari öffnen"</b>
wählen, dann weiter unten.</div>
</div>
<div class="steps">
<div class="step">
<div class="step-badge">
<span class="step-num">1</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M4 13v6a2 2 0 002 2h12a2 2 0 002-2v-6"/></svg>
<span class="tap-ring" style="bottom: -2px; right: -2px;"></span>
</div>
<div class="step-body">
<h3>Teilen-Symbol antippen</h3>
<p>In der Leiste am unteren (bei manchen Geräten oberen) Bildschirmrand
von Safari – das Quadrat mit dem Pfeil nach oben.
Siehst du dort nur <kbd>•••</kbd>? Dann zuerst darauf tippen – bei
neueren Safari-Versionen ist das Teilen-Symbol dahinter versteckt.</p>
</div>
</div>
<div class="step">
<div class="step-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="4"/><path d="M12 9v6M9 12h6"/></svg>
</div>
<div class="step-body">
<h3>„Zum Home-Bildschirm" wählen</h3>
<p>In der Liste etwas herunterscrollen, falls es nicht sofort zu sehen
ist.</p>
</div>
</div>
<div class="step">
<div class="step-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<div class="step-body">
<h3>Oben rechts auf „Hinzufügen" tippen</h3>
<p>Der Name „PioMint" ist schon vorausgefüllt – einfach so lassen.</p>
</div>
</div>
</div>
</div>
<!-- Android -->
<div data-platform-section="android">
<div class="browser-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01" stroke-linecap="round"/></svg>
<div>Am zuverlässigsten in <b>Chrome</b>. Manche Geräte (z. B. Samsung Internet)
nennen den Menüpunkt etwas anders, das Prinzip ist aber gleich.</div>
</div>
<div class="steps">
<div class="step">
<div class="step-badge">
<span class="step-num">1</span>
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
<span class="tap-ring" style="bottom: -2px; right: -2px;"></span>
</div>
<div class="step-body">
<h3>Menü oben rechts öffnen</h3>
<p>Die drei Punkte <kbd>⋮</kbd> neben der Adressleiste antippen.</p>
</div>
</div>
<div class="step">
<div class="step-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M12 8v5M9.5 10.5h5" stroke-linecap="round"/></svg>
</div>
<div class="step-body">
<h3>„App installieren" antippen</h3>
<p>Manchmal schlägt Chrome das schon automatisch über ein kleines
Banner vor – dann reicht ein Tipp darauf.</p>
</div>
</div>
<div class="step">
<div class="step-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<div class="step-body">
<h3>Mit „Installieren" bestätigen</h3>
<p>Das Icon landet direkt auf deinem Startbildschirm.</p>
</div>
</div>
</div>
</div>
<div class="done-card">
<h3>Fertig! 🎉</h3>
<p>PioMint öffnet sich jetzt wie eine eigene App – ohne Adressleiste, mit
eigenem Icon. Einfach drauftippen und loslegen.</p>
</div>
<footer>
Fragen oder klappt etwas nicht? Schreib uns an
<a href="mailto:app-feedback@zielke.studio">app-feedback@zielke.studio</a>
oder in der <a href="https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ" target="_blank" rel="noopener noreferrer">WhatsApp-Community</a>.
</footer>
</div>
<script>
function showPlatform(platform) {
document.querySelectorAll('[data-platform-section]').forEach(function (el) {
el.classList.toggle('active', el.dataset.platformSection === platform);
});
document.getElementById('tab-ios').classList.toggle('active', platform === 'ios');
document.getElementById('tab-android').classList.toggle('active', platform === 'android');
try { localStorage.setItem('piomintInstallPlatform', platform); } catch (error) {}
}
(function () {
var saved = null;
try { saved = localStorage.getItem('piomintInstallPlatform'); } catch (error) {}
var ua = navigator.userAgent || '';
var detected = /Android/i.test(ua) ? 'android' : 'ios';
showPlatform(saved || detected);
})();
</script>
</body>
</html>

@ -1,22 +1,22 @@
<br> <br>
<div class="text-center"> <h1>Update Time!</h1>
<h1 style="margin-bottom: 0;">Kurzer Hotfix <span style="color: rgb(120,170,86);">26.3</span></h1> <br>
</div> <h5>v1.3.4</h5>
<br> <p>
<p> <b style="color: red">Fehler Behoben! 🔥</b>
Hey! 👋 <br>Nun auch wieder 2025!
<br>Zwei gemeldete Fehler sind jetzt behoben: <br>
</p> <br><b>Was ist neu?</b>
<ul style="padding-left: 1.25rem;"> <br> Aufrund des Dienstjahres was nicht mit Kalender
<li>🧮 <b>Falsche Berechnung</b> – wenn man beim Erstellen/Bearbeiten eines Eintrags <br> übereinstimmt, geht es gegen die Natur.
die "0" bei den Minuten gelöscht hat, konnten die Monats- und Jahresstunden <br>
durcheinandergeraten. Behoben.</li> <!-- <br><b>Hinweise:</b>
<li>📅 <b>Datumsfeld zu breit</b> – das Datumsfeld beim neuen Eintrag lief auf <br> 1. Backup muss Manuell gemacht werden (aktuell)!
manchen Geräten über den Rand der Karte hinaus. Behoben.</li> <br> 2. Backup muss Gespeichert werden! */}}
</ul> <br> -->
<p> <br>Danke für die Geduld und das du Piomint nutzt!
Danke fürs Melden! 🙏
<br><b>Gruß Samuel</b> <br><b>Gruß Samuel</b>
</p>
</p>
<br> <br>
<input type="text" name="versionnumber" id="versionnumber" class="d-none" value="26.3"> <input type="text" name="versionnumber" id="versionnumber" class="d-none" value="1.3.4-0">

@ -10,7 +10,7 @@ urlpatterns = [
path("home/", views.home), path("home/", views.home),
path('offline/', TemplateView.as_view(template_name="offline.html")), path('offline/', TemplateView.as_view(template_name="offline.html")),
path("install/", views.install), path("install/", views.install),
path("v1321/", views.v1321), path("v1321/", views.v1322),
path("v1322/", views.v1322), path("v1322/", views.v1322),
path("v133/", views.v133), path("v133/", views.v133),
path("v1332/", views.v1332), path("v1332/", views.v1332),
@ -18,10 +18,4 @@ urlpatterns = [
path("v1334/", views.v1334), path("v1334/", views.v1334),
path("v1335/", views.v1335), path("v1335/", views.v1335),
path("v1340/", views.v1340), path("v1340/", views.v1340),
path("v260/", views.v260),
path("v261/", views.v261),
path("v262/", views.v262),
path("v263/", views.v263),
path("track/", views.track_usage),
path("dashboard/", views.usage_dashboard),
] ]

@ -1,32 +0,0 @@
def parse_user_agent(user_agent):
"""Sehr einfache, dependency-freie Grobzuordnung von Plattform/Browser aus dem
User-Agent-String - reicht für die Dashboard-Statistik, muss nicht exakt sein."""
ua = (user_agent or "").lower()
if "iphone" in ua or "ipad" in ua or "ipod" in ua:
platform = "iOS"
elif "android" in ua:
platform = "Android"
elif "windows" in ua:
platform = "Windows"
elif "macintosh" in ua or "mac os x" in ua:
platform = "macOS"
elif "linux" in ua:
platform = "Linux"
else:
platform = "Unbekannt"
if "edg/" in ua or "edga" in ua or "edgios" in ua:
browser = "Edge"
elif "opr/" in ua or "opera" in ua:
browser = "Opera"
elif "firefox" in ua or "fxios" in ua:
browser = "Firefox"
elif "crios" in ua or "chrome" in ua:
browser = "Chrome"
elif "safari" in ua:
browser = "Safari"
else:
browser = "Unbekannt"
return platform, browser

@ -1,17 +1,4 @@
import json
from django.contrib.admin.views.decorators import staff_member_required
from django.db.models import Count, Max, Min
from django.http import JsonResponse
from django.shortcuts import render, HttpResponse, redirect from django.shortcuts import render, HttpResponse, redirect
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from datetime import timedelta
from .models import UsageEvent
from .utils import parse_user_agent
# Create your views here. # Create your views here.
def index(request): def index(request):
@ -21,115 +8,31 @@ def install(request):
return render(request, "app/install.html") return render(request, "app/install.html")
def home(request): def home(request):
return redirect("/app/v263") return redirect("/app/v1340")
def login(request): def login(request):
return render(request, "app/login.html") return render(request, "app/login.html")
def v1321(request): def v1321(request):
return redirect("/app/v263") return redirect("/app/v1340")
def v1322(request): def v1322(request):
return redirect("/app/v263") return redirect("/app/v1340")
def v133(request): def v133(request):
return redirect("/app/v263") return redirect("/app/v1340")
def v1332(request): def v1332(request):
return redirect("/app/v263") return redirect("/app/v1340")
def v1333(request): def v1333(request):
return redirect("/app/v263") return redirect("/app/v1340")
def v1334(request): def v1334(request):
return redirect("/app/v263") return redirect("/app/v1340")
def v1335(request): def v1335(request):
return redirect("/app/v263") return redirect("app/v1340")
def v1340(request): def v1340(request):
return redirect("/app/v263")
def v260(request):
return redirect("/app/v263")
def v261(request):
return redirect("/app/v263")
def v262(request):
return redirect("/app/v263")
def v263(request):
return render(request, "app/home.html") return render(request, "app/home.html")
# ---------------------------------------------------------------------------
# Anonyme Nutzungsstatistik fürs Admin-Dashboard
# ---------------------------------------------------------------------------
@csrf_exempt
@require_POST
def track_usage(request):
"""Nimmt ein anonymes Nutzungssignal entgegen (ein Aufruf pro Gerät und Tag,
siehe home01.js: pingUsageIfNeeded()). Kein CSRF-Schutz nötig, da rein additiv
und ohne jeden Personenbezug - device_id kommt aus dem localStorage des Geräts."""
try:
data = json.loads(request.body or "{}")
except json.JSONDecodeError:
data = {}
device_id = str(data.get("device_id", "")).strip()[:64]
if not device_id:
return JsonResponse({"ok": False, "error": "device_id fehlt"}, status=400)
user_agent = request.META.get("HTTP_USER_AGENT", "")
platform, browser = parse_user_agent(user_agent)
UsageEvent.objects.create(
device_id=device_id,
app_version=str(data.get("app_version", ""))[:20],
user_agent=user_agent[:1000],
platform=platform,
browser=browser,
is_standalone=bool(data.get("standalone", False)),
)
return JsonResponse({"ok": True})
@staff_member_required
def usage_dashboard(request):
"""Nutzungs-Dashboard für Admins - nur für angemeldete Staff-Nutzer (Django-Admin-Login)."""
now = timezone.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
events = UsageEvent.objects.all()
# "Nutzer" = Gerät hat PioMint mindestens einmal als installierte PWA
# (Standalone) geöffnet - nur das ist echte Nutzung. Ein Gerät, das die
# Seite nur im Browser-Tab geöffnet hat, ist ein "Aufrufer", kein Nutzer.
user_device_ids = events.filter(is_standalone=True).values_list("device_id", flat=True).distinct()
users = events.filter(device_id__in=user_device_ids)
visitors_total = events.exclude(device_id__in=user_device_ids).values("device_id").distinct().count()
context = {
"total_connections": events.count(),
"visitors_total": visitors_total,
"users_total": user_device_ids.count(),
"users_today": users.filter(created_at__gte=today_start).values("device_id").distinct().count(),
"users_7d": users.filter(created_at__gte=now - timedelta(days=7)).values("device_id").distinct().count(),
"users_30d": users.filter(created_at__gte=now - timedelta(days=30)).values("device_id").distinct().count(),
"by_platform": users.values("platform").annotate(count=Count("device_id", distinct=True)).order_by("-count"),
"by_browser": users.values("browser").annotate(count=Count("device_id", distinct=True)).order_by("-count"),
"device_summary": (
events.values("device_id")
.annotate(
ping_count=Count("id"),
first_seen=Min("created_at"),
last_seen=Max("created_at"),
standalone_any=Max("is_standalone"),
)
.order_by("-last_seen")
),
"recent_events": events[:30],
}
return render(request, "app/dashboard.html", context)

@ -1,7 +0,0 @@
from django.conf import settings
def static_version(request):
"""Stellt STATIC_VERSION allen Templates zur Verfügung, um CSS/JS-URLs mit
?v=<version> zu versehen (Cache-Busting bei Bestandsnutzern, siehe settings.py)."""
return {'STATIC_VERSION': getattr(settings, 'STATIC_VERSION', '')}

@ -9,7 +9,7 @@ PWA_APP_BACKGROUND_COLOR = '#9FEDD7'
PWA_APP_DISPLAY = 'standalone' PWA_APP_DISPLAY = 'standalone'
PWA_APP_SCOPE = '/' PWA_APP_SCOPE = '/'
PWA_APP_ORIENTATION = 'portrait' PWA_APP_ORIENTATION = 'portrait'
PWA_APP_START_URL = '/app/v263/' PWA_APP_START_URL = '/app/v1340/'
PWA_APP_STATUS_BAR_COLOR = 'black-translucent' PWA_APP_STATUS_BAR_COLOR = 'black-translucent'
PWA_SERVICE_WORKER_PATH = os.path.join(BASE_DIR, 'app/static/app/js', 'serviceworker.js') PWA_SERVICE_WORKER_PATH = os.path.join(BASE_DIR, 'app/static/app/js', 'serviceworker.js')
PWA_APP_ICONS = [ PWA_APP_ICONS = [

@ -29,12 +29,6 @@ DEBUG = True
ALLOWED_HOSTS = ['*'] if DEBUG else ['localhost', 'app.piomint.de'] ALLOWED_HOSTS = ['*'] if DEBUG else ['localhost', 'app.piomint.de']
# Ohne das schlägt jedes POST-Formular (z.B. der Admin-Login) mit "Origin checking
# failed" fehl, sobald die Seite über HTTPS läuft: der Server sitzt hinter einem
# Reverse-Proxy, Django kennt seine eigene Produktions-Domain sonst nicht als
# vertrauenswürdigen Origin.
CSRF_TRUSTED_ORIGINS = ['https://app.piomint.de']
# Application definition # Application definition
@ -78,7 +72,6 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'piomint.context_processors.static_version',
], ],
}, },
}, },
@ -91,18 +84,10 @@ WSGI_APPLICATION = 'piomint.wsgi.application'
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases # https://docs.djangoproject.com/en/5.1/ref/settings/#databases
#STANDART DATENBANK #STANDART DATENBANK
# SQLite liegt bewusst in einem eigenen data/-Verzeichnis außerhalb des Quellcodes:
# Auf dem Server läuft Gunicorn als www-data, das Deployment (git pull/migrate) aber
# als root - nur so kann www-data die DB beschreiben, ohne dass der ganze Projektordner
# (inkl. .git) für den Webserver-Prozess beschreibbar sein muss. Verzeichnis wird bei
# Bedarf automatisch angelegt; data/ ist in .gitignore, landet also nie im Repo.
DATA_DIR = BASE_DIR / 'data'
DATA_DIR.mkdir(parents=True, exist_ok=True)
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': DATA_DIR / 'db.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3',
} }
} }
@ -155,14 +140,6 @@ USE_TZ = True
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATIC_ROOT = '/var/www/piomint/static/' # Directory to collect static files. STATIC_ROOT = '/var/www/piomint/static/' # Directory to collect static files.
# Cache-Busting für CSS/JS: wird als ?v=<STATIC_VERSION> an alle {% static %}-Links in
# base.html/home.html angehängt (siehe piomint/context_processors.py). Ohne das können
# bereits installierte PWAs auf einer alten, gecachten CSS/JS-Version hängen bleiben,
# während schon neues HTML ausgeliefert wird - das führte u.a. zu eingefrorenen Screens
# (kein Scroll, kein Button erreichbar) bei Bestandsnutzern. Bei jedem Release erhöhen,
# zusammen mit der Versions-URL in app/urls.py / app/views.py.
STATIC_VERSION = '26.3'
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

@ -1,461 +1,24 @@
{% load static %} <section data-cid="slide-05-7059bc59" class="slide fade-6 kenBurns">
<!DOCTYPE html> <div class="content">
<html lang="de"> <div class="container">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="PioMint: die einfache, anonyme App für Pioniere, um Dienststunden zu erfassen. Kein Account, keine Cloud – alles bleibt auf deinem Gerät.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;1,9..144,500;1,9..144,600&display=swap" rel="stylesheet">
<title>PioMint – von einem Pio, für Pios</title>
<style>
:root {
--teal: #026670;
--teal-deep: #013e45;
--mint: rgb(120, 170, 86);
--mint-soft: #eef3e7;
--paper: #fbf9f4;
--ink: #1c2723;
--ink-soft: #5b6b63;
}
* { box-sizing: border-box; }
html, body {
overflow-x: hidden;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.55;
}
a { color: inherit; }
.display {
font-family: "Fraunces", Georgia, serif;
}
img { max-width: 100%; display: block; }
.wrap {
max-width: 1080px;
margin: 0 auto;
padding: 0 1.5rem;
}
/* --- Top mark --- */
.topbar {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 1.5rem 1.5rem 0 1.5rem;
max-width: 1080px;
margin: 0 auto;
}
.topbar img {
width: 34px;
height: 34px;
border-radius: 9px;
}
.topbar span {
font-weight: 700;
letter-spacing: 0.02em;
}
.topbar span b { color: var(--mint); }
/* --- Hero --- */
.hero {
display: grid;
grid-template-columns: 1fr;
gap: 2.5rem;
align-items: center;
padding: 2.5rem 1.5rem 3rem 1.5rem;
max-width: 1080px;
margin: 0 auto;
}
@media (min-width: 900px) {
.hero {
grid-template-columns: 1.05fr 0.95fr;
padding-top: 4rem;
gap: 3rem;
}
}
.hero-quote {
font-size: clamp(2rem, 6vw, 3.1rem);
font-weight: 500;
font-style: italic;
line-height: 1.12;
margin: 0 0 1.25rem 0;
position: relative;
}
.hero-quote .squiggle {
display: block;
width: 132px;
height: 14px;
margin-top: 0.4rem;
}
.hero-sub {
font-size: 1.08rem;
color: var(--ink-soft);
max-width: 34rem;
margin: 0 0 1.75rem 0;
}
.cta {
display: inline-flex;
align-items: center;
gap: 0.6rem;
background: var(--teal);
color: white;
font-weight: 700;
font-size: 1.05rem;
padding: 0.9rem 1.6rem;
border-radius: 999px;
text-decoration: none;
box-shadow: 0 0.6rem 1.4rem rgba(2, 102, 112, 0.28);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.cta:hover {
transform: translateY(-2px);
box-shadow: 0 0.9rem 1.8rem rgba(2, 102, 112, 0.34);
color: white;
}
.cta svg { width: 20px; height: 20px; }
.cta-note {
margin: 0.85rem 0 0 0;
font-size: 0.85rem;
color: var(--ink-soft);
}
/* --- Phone frame --- */
.phone {
justify-self: center;
width: min(280px, 78vw);
border-radius: 2.4rem;
padding: 0.55rem;
background: linear-gradient(160deg, #ffffff, #eef1ee);
box-shadow: 0 1.5rem 3rem rgba(2, 30, 33, 0.18), 0 0 0 1px rgba(2, 30, 33, 0.05);
}
.phone img {
border-radius: 1.9rem;
width: 100%;
}
.phone.float {
animation: floaty 5s ease-in-out infinite;
}
@keyframes floaty {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@media (prefers-reduced-motion: reduce) {
.phone.float { animation: none; }
}
/* --- Feature sections --- */
.features {
background: white;
padding: 3.5rem 0 4rem 0;
border-top: 1px solid rgba(2, 30, 33, 0.06);
border-bottom: 1px solid rgba(2, 30, 33, 0.06);
}
.features-head {
text-align: center;
margin-bottom: 2.75rem;
}
.features-head .eyebrow {
color: var(--mint);
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.features-head h2 {
font-family: "Fraunces", Georgia, serif;
font-style: italic;
font-weight: 500;
font-size: clamp(1.7rem, 4vw, 2.3rem);
margin: 0.4rem 0 0 0;
}
.feature-row {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
align-items: center;
padding: 2.5rem 1.5rem;
max-width: 1080px;
margin: 0 auto;
}
.feature-row:not(:last-child) {
border-bottom: 1px dashed rgba(2, 30, 33, 0.12);
}
@media (min-width: 900px) {
.feature-row {
grid-template-columns: 1fr 1fr;
gap: 3.5rem;
}
.feature-row.reverse .feature-text { order: 2; }
.feature-row.reverse .feature-shot { order: 1; }
}
.feature-row-center {
grid-template-columns: 1fr !important;
text-align: center;
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
.feature-row-center .feature-text {
max-width: 34rem;
margin: 0 auto;
}
.feature-row-center .feature-callout {
text-align: left;
}
.feature-icon {
width: 46px;
height: 46px;
border-radius: 14px;
background: var(--mint-soft);
color: var(--teal);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.feature-icon svg { width: 22px; height: 22px; }
.feature-text h3 {
font-size: 1.4rem;
font-weight: 700;
margin: 0 0 0.6rem 0;
}
.feature-text p {
color: var(--ink-soft);
font-size: 1.02rem;
margin: 0;
}
.feature-callout {
margin-top: 1.1rem;
background: var(--mint-soft);
border-radius: 0.9rem;
padding: 0.9rem 1.1rem;
font-size: 0.92rem;
color: var(--teal-deep);
display: flex;
gap: 0.6rem;
}
.feature-callout svg {
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 0.15rem;
color: var(--mint);
}
.feature-shot {
justify-self: center;
}
.feature-shot .phone {
width: min(230px, 62vw);
}
/* --- Closing CTA --- */
.closing {
background: linear-gradient(160deg, var(--teal) 0%, var(--teal-deep) 100%);
color: white;
text-align: center;
padding: 4rem 1.5rem;
}
.closing h2 {
font-family: "Fraunces", Georgia, serif;
font-style: italic;
font-weight: 500;
font-size: clamp(1.8rem, 5vw, 2.6rem);
margin: 0 0 0.75rem 0;
}
.closing p {
color: rgba(255, 255, 255, 0.82);
max-width: 30rem;
margin: 0 auto 1.75rem auto;
}
.closing .cta {
background: var(--mint);
box-shadow: 0 0.6rem 1.4rem rgba(120, 170, 86, 0.35);
}
/* --- Footer --- */
footer {
padding: 2.5rem 1.5rem 3rem 1.5rem;
text-align: center;
font-size: 0.85rem;
color: var(--ink-soft);
}
footer a {
color: var(--teal);
font-weight: 600;
text-decoration: none;
}
footer .links {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.2rem;
justify-content: center;
margin-bottom: 1rem;
}
footer .fineprint {
max-width: 32rem;
margin: 0 auto;
opacity: 0.85;
}
</style>
</head>
<body>
<div class="topbar">
<img src="{% static 'app/images/appleicon_whitebg.png' %}" alt="">
<span>PIO<b>MINT</b></span>
</div>
<section class="hero">
<div>
<p class="hero-quote display">
Von einem Pio.<br>Für Pios gemacht.
<svg class="squiggle" viewBox="0 0 132 14" fill="none">
<path d="M2 10C20 2 34 2 50 8C66 14 80 2 96 6C108 9 118 6 130 2" stroke="var(--mint)" stroke-width="3" stroke-linecap="round"/>
</svg>
</p>
<p class="hero-sub">
Ich hab PioMint gebaut, weil ich selbst jeden Monat meine Stunden
eintragen musste – und keine der bestehenden Apps wollte. Zu
kompliziert, zu englisch, zu viel drumherum. Also hab ich meine
eigene gemacht. Jetzt darfst du sie auch benutzen.
</p>
<a class="cta" href="/app/install/">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M4 13v6a2 2 0 002 2h12a2 2 0 002-2v-6"/></svg>
Jetzt installieren
</a>
<p class="cta-note">Kostenlos. Ohne Account. In unter einer Minute.</p>
</div>
<div class="phone float">
<img src="{% static 'app/images/landing/screen-uebersicht.png' %}" alt="PioMint Dashboard: Jahr, Monat, Timer und Aktivitäten auf einen Blick">
</div>
</section>
<section class="features">
<div class="features-head">
<div class="eyebrow">Warum PioMint?</div>
<h2 class="display">Nichts, was du nicht brauchst.</h2>
</div>
<div class="wrap"> <div class="wrap">
<div class="feature-row"> <ul class="flex">
<div class="feature-text"> <li class="col-12-12">
<div class="feature-icon"> <div class="fix-4-12">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg> <h1>PioMint</h1>
</div> <a href="#" class="button hollow ae-2 cropLeft"><img src="assets/img/appstore.jpg" height="63"/></a><a href="#" class="button hollow ae-3 cropRight"><img src="assets/img/googleplay.jpg" height="63"/></a>
<h3>Einfach, nicht kompliziert</h3>
<p>Keine Diagramme, keine Statistik-Flut, kein Menü-Dschungel.
Nur deine Stunden – klar und auf einen Blick.</p>
</div>
<div class="feature-shot">
<div class="phone"><img src="{% static 'app/images/landing/screen-liste.png' %}" alt="Übersichtliche Liste aller Aktivitäten in PioMint"></div>
</div>
</div>
<div class="feature-row reverse">
<div class="feature-text">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l3 2M9 2h6M12 2v3"/></svg>
</div>
<h3>Timer eingebaut</h3>
<p>Starten, pausieren, fertig. Die Zeit läuft von selbst mit –
du musst am Ende nichts mehr zusammenrechnen.</p>
</div>
<div class="feature-shot">
<div class="phone"><img src="{% static 'app/images/landing/screen-timer.png' %}" alt="Laufender Timer in PioMint"></div>
</div> </div>
</li>
<li class="col-12-12">
<div class="fix-6-12 ae-2 margin-top-3">
<img class="iphone-5" width="380" src="assets/img/iphone-5.png" alt="iPhone App" data-action="zoom"/>
</div> </div>
</li>
</ul>
<div class="feature-row feature-row-center">
<div class="feature-text">
<div class="feature-icon" style="margin-left: auto; margin-right: auto;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6l8-4z"/></svg>
</div>
<h3>100&nbsp;% anonym</h3>
<p>Kein Account, kein Login, keine Cloud. Deine Daten bleiben
ausschließlich auf deinem Gerät – sonst nirgends.</p>
<div class="feature-callout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
<span>Und genau weil alles nur lokal liegt, kannst du jederzeit
ein Backup machen und es auf einem neuen Handy wieder
einspielen – deine Daten gehen beim Wechsel nicht verloren.</span>
</div> </div>
</div> </div>
</div> </div>
<div class="background" style="background-image:url(assets/img/background/img-05.jpg)"></div>
</div>
</section> </section>
<section class="closing">
<h2 class="display">Bereit?</h2>
<p>Einmal einrichten, dann liegt PioMint als eigenes Icon auf deinem
Homescreen – ganz ohne App Store.</p>
<a class="cta" href="/app/install/">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M4 13v6a2 2 0 002 2h12a2 2 0 002-2v-6"/></svg>
PioMint installieren
</a>
</section>
<footer>
<div class="links">
<a href="mailto:app-feedback@zielke.studio">Feedback</a>
<a href="https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ" target="_blank" rel="noopener noreferrer">WhatsApp-Community</a>
<a href="https://www.zielke.studio/" target="_blank" rel="noopener noreferrer">Impressum</a>
</div>
<p class="fineprint">
PioMint ist ein privates Herzensprojekt, kein kommerzielles Produkt.
Das Impressum stellt zielke.studio (Button unten auf der Seite).
</p>
</footer>
</body>
</html>

Loading…
Cancel
Save

Powered by TurnKey Linux.