Compare commits

...

15 Commits

Author SHA1 Message Date
Samuel Zielke 24160fe860 Fix: Template-Kommentar auf Install-Seite wurde als Text angezeigt
2 weeks ago
Samuel Zielke ec5bc41195 Fix: Install-Seite legte nur Lesezeichen statt PWA an
2 weeks ago
Samuel Zielke 5dd37373fa Dashboard: Dark Mode mit manuellem Umschalter, in localStorage gemerkt
3 weeks ago
Samuel Zielke e14f58769c Dashboard: Nutzer (installierte PWA) von reinen Aufrufern trennen
3 weeks ago
Samuel Zielke 6a6a1f5962 Release 26.3
3 weeks ago
Samuel Zielke 4f9f357f9c Dashboard: Verbindungen insgesamt getrennt von eindeutigen Geräten, Geräte-Übersicht ergänzt
3 weeks ago
Samuel Zielke 357ec010d4 Landingpage: "Von einem Pio, für Pios"
3 weeks ago
Samuel Zielke aba8cf88d6 Install-Anleitung: Hinweis auf verstecktes Teilen-Symbol (iOS)
3 weeks ago
Samuel Zielke 1e6a83514f Install-Anleitung modernisiert: iOS + Android, eigene Seite
3 weeks ago
Samuel Zielke b08aa8f7e0 Fix: CSRF_TRUSTED_ORIGINS für HTTPS-Produktion ergänzt
3 weeks ago
Samuel Zielke af69060f2c DB in eigenes data/-Verzeichnis verschieben (Schreibrechte-Fix)
3 weeks ago
Samuel Zielke 710220ff71 Release 26.2: Partner-Feld, Feedback-Mail, Nutzungs-Dashboard
3 weeks ago
Samuel Zielke a8c798fbe0 Optionales Partner-Feld für Diensteinträge
3 weeks ago
Samuel Zielke 3297fde0c0 Feedback-E-Mail in den Einstellungen verlinken
3 weeks ago
Samuel Zielke 6ff0813f6f Admin-Dashboard: anonyme Nutzungsstatistik
3 weeks ago

2
.gitignore vendored

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

@ -152,6 +152,10 @@ bisherigen Versions-URLs auf die aktuellste weiter.
alte, gecachte Version zu behalten. Ohne diesen Schritt können CSS/JS und HTML alte, gecachte Version zu behalten. Ohne diesen Schritt können CSS/JS und HTML
auseinanderlaufen (führte schon zu eingefrorenen Screens bei Bestandsnutzern) – auseinanderlaufen (führte schon zu eingefrorenen Screens bei Bestandsnutzern) –
**nicht vergessen!** **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 ## Roadmap / offene Punkte
@ -162,8 +166,9 @@ bisherigen Versions-URLs auf die aktuellste weiter.
## Mitwirken ## Mitwirken
Feedback, Bug-Reports und Ideen gerne über die Feedback, Bug-Reports und Ideen gerne über die
[WhatsApp-Community](https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ) oder direkt an [WhatsApp-Community](https://chat.whatsapp.com/Cy4laGaEdLP1DZbhN04zRQ), per E-Mail an
Samuel. **app-feedback@zielke.studio** (auch direkt in der App unter Einstellungen → Kontakt
verlinkt) oder direkt an Samuel.
## Lizenz ## Lizenz

@ -1,3 +1,12 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import UsageEvent
@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,3 +1,27 @@
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}"

@ -310,3 +310,16 @@ 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.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@ -361,6 +361,14 @@ function fillTableWithEntries(entries, sortField = 'date', sortDirection = 'asc'
} 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');
@ -371,6 +379,7 @@ 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 {

@ -52,8 +52,61 @@ 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
@ -274,7 +327,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) const duration = ((parseInt(hours.value) || 0)*60) + (parseInt(mins.value) || 0)
const newEntryData = { const newEntryData = {
date: document.getElementById('datum').value, date: document.getElementById('datum').value,
@ -282,6 +335,7 @@ 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) => {
@ -292,6 +346,7 @@ 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()
} }
}) })
@ -645,6 +700,10 @@ 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}`)
@ -663,10 +722,11 @@ 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), duration: ((parseInt(edithours.value) || 0)*60) + (parseInt(editMins.value) || 0),
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()
@ -784,16 +844,19 @@ 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;
} }
} }
@ -824,17 +887,20 @@ 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: ${editMinuten.value}`) console.log(`Status: ${editallowToSend} - Check: ${editsonstigesCheck.checked} - Input: ${editsonstigesInput.value} - Stunden: ${edithours.value} - Min: ${editMins.value}`)
} }

@ -15,7 +15,7 @@
<title>PioMint</title> <title>PioMint</title>
</head> </head>
<body id="body" style="overflow: hidden;" class="bg-white-color"> <body id="body" style="overflow: hidden;" class="bg-white-color" data-app-version="{{ STATIC_VERSION }}">
<!-- 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;">
@ -67,6 +67,10 @@
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>

@ -0,0 +1,249 @@
{% 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>

@ -191,6 +191,12 @@
<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>
@ -258,6 +264,12 @@
<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>

@ -1,15 +1,424 @@
{% extends 'app/base.html' %} {% load pwa %}
{% load static %}
{% block content %} <!DOCTYPE html>
<h1>Installieren</h1> <html lang="de">
<br> <head>
<p> <meta charset="UTF-8">
Folgende Schritte sind nötig um PioMint unter IOS zu installieren: <meta name="viewport" content="width=device-width, initial-scale=1.0">
<br> <link rel="stylesheet" href="{% static 'app/css/bootstrap.css' %}?v={{ STATIC_VERSION }}">
1. Auf das <b>Teilen</b> Symbol klicken. {% comment %}
<br> Manifest + Apple-Meta-Tags: ohne die legt "Zum Home-Bildschirm"/"App installieren"
2. Auf <b>Zum Home-Bildschirm hinzufügen</b> klicken. nur ein Lesezeichen auf /install/ an statt die PWA mit start_url zu installieren
<br> {% endcomment %}
3. Auf <b>Hinzufügen</b> klicken. {% progressive_web_app_meta %}
</p>
{% endblock %} <title>PioMint installieren</title>
<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"> <div class="text-center">
<h1 style="margin-bottom: 0;">Kurzer Hotfix <span style="color: rgb(120,170,86);">26.1</span></h1> <h1 style="margin-bottom: 0;">Kurzer Hotfix <span style="color: rgb(120,170,86);">26.3</span></h1>
</div> </div>
<br> <br>
<p> <p>
Hey! 👋 Hey! 👋
<br>Kurz hinterher: Bei manchen bereits installierten Geräten konnte diese <br>Zwei gemeldete Fehler sind jetzt behoben:
Update-Ansicht hängen bleiben – kein Scrollen möglich, "Alles klar"-Button nicht
erreichbar.
<br>Das ist jetzt behoben.
</p> </p>
<ul style="padding-left: 1.25rem;">
<li>🧮 <b>Falsche Berechnung</b> – wenn man beim Erstellen/Bearbeiten eines Eintrags
die "0" bei den Minuten gelöscht hat, konnten die Monats- und Jahresstunden
durcheinandergeraten. Behoben.</li>
<li>📅 <b>Datumsfeld zu breit</b> – das Datumsfeld beim neuen Eintrag lief auf
manchen Geräten über den Rand der Karte hinaus. Behoben.</li>
</ul>
<p> <p>
War dein Bildschirm gerade eingefroren? Einmal die App schließen, kurz warten und Danke fürs Melden! 🙏
neu öffnen – jetzt sollte wieder alles normal laufen.
</p>
<p>
Danke für die Geduld und 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.1"> <input type="text" name="versionnumber" id="versionnumber" class="d-none" value="26.3">

@ -20,4 +20,8 @@ urlpatterns = [
path("v1340/", views.v1340), path("v1340/", views.v1340),
path("v260/", views.v260), path("v260/", views.v260),
path("v261/", views.v261), path("v261/", views.v261),
path("v262/", views.v262),
path("v263/", views.v263),
path("track/", views.track_usage),
path("dashboard/", views.usage_dashboard),
] ]

@ -0,0 +1,32 @@
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,4 +1,17 @@
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):
@ -8,37 +21,115 @@ def install(request):
return render(request, "app/install.html") return render(request, "app/install.html")
def home(request): def home(request):
return redirect("/app/v261") return redirect("/app/v263")
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/v261") return redirect("/app/v263")
def v1322(request): def v1322(request):
return redirect("/app/v261") return redirect("/app/v263")
def v133(request): def v133(request):
return redirect("/app/v261") return redirect("/app/v263")
def v1332(request): def v1332(request):
return redirect("/app/v261") return redirect("/app/v263")
def v1333(request): def v1333(request):
return redirect("/app/v261") return redirect("/app/v263")
def v1334(request): def v1334(request):
return redirect("/app/v261") return redirect("/app/v263")
def v1335(request): def v1335(request):
return redirect("/app/v261") return redirect("/app/v263")
def v1340(request): def v1340(request):
return redirect("/app/v261") return redirect("/app/v263")
def v260(request): def v260(request):
return redirect("/app/v261") return redirect("/app/v263")
def v261(request): 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)

@ -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/v261/' PWA_APP_START_URL = '/app/v263/'
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,6 +29,12 @@ 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
@ -85,10 +91,18 @@ 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': BASE_DIR / 'db.sqlite3', 'NAME': DATA_DIR / 'db.sqlite3',
} }
} }
@ -147,7 +161,7 @@ STATIC_ROOT = '/var/www/piomint/static/' # Directory to collect static files.
# während schon neues HTML ausgeliefert wird - das führte u.a. zu eingefrorenen Screens # 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, # (kein Scroll, kein Button erreichbar) bei Bestandsnutzern. Bei jedem Release erhöhen,
# zusammen mit der Versions-URL in app/urls.py / app/views.py. # zusammen mit der Versions-URL in app/urls.py / app/views.py.
STATIC_VERSION = '26.1' 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,24 +1,461 @@
<section data-cid="slide-05-7059bc59" class="slide fade-6 kenBurns"> {% load static %}
<div class="content"> <!DOCTYPE html>
<div class="container"> <html lang="de">
<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">
<ul class="flex"> <div class="feature-row">
<li class="col-12-12"> <div class="feature-text">
<div class="fix-4-12"> <div class="feature-icon">
<h1>PioMint</h1> <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>
<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> </div>
</div> <h3>Einfach, nicht kompliziert</h3>
</li> <p>Keine Diagramme, keine Statistik-Flut, kein Menü-Dschungel.
<li class="col-12-12"> Nur deine Stunden – klar und auf einen Blick.</p>
<div class="fix-6-12 ae-2 margin-top-3"> </div>
<img class="iphone-5" width="380" src="assets/img/iphone-5.png" alt="iPhone App" data-action="zoom"/> <div class="feature-shot">
</div> <div class="phone"><img src="{% static 'app/images/landing/screen-liste.png' %}" alt="Übersichtliche Liste aller Aktivitäten in PioMint"></div>
</li> </div>
</ul> </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>
<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> </section>
</div>
<div class="background" style="background-image:url(assets/img/background/img-05.jpg)"></div> <section class="closing">
</section> <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.