Compare commits

..

6 commits

Author SHA1 Message Date
250f8390cf Timeline: Sprungleiste über dem Monat/Jahr-Pill
Ein Tipp auf das Pill öffnet eine Leiste mit allen Jahren, die Ereignisse
enthalten, samt Dichte (Punkte bis drei, sonst Zahl); ab zwölf Jahren
kommt eine Jahrzehnte-Zeile zum Scrubben dazu. Hat das gewählte Jahr
Ereignisse in mehreren Monaten, erscheint eine Monatszeile, in der nur
belegte Monate aktiv sind. Ein Jahr springt sofort, ein Monat zentriert
dessen Ereignisse; Tippen außerhalb oder Escape schließt.

Ersetzt das q-menu im Pill, das durch doppeltes Umschalten nie sichtbar
wurde. Die Timeline liefert dafür einen Jahr/Monat-Index und kann auf
einen Monat zentrieren.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 09:31:35 +00:00
ab23c9f4ef Client-Logging, getrennte Rate-Limits und Ladezustand für Bilder
Auf dem Testserver lief die App beim Scrollen langer Timelines in ein 429:
nach jeder Scroll-Pause ging sofort ein PUT /settings raus, und jedes
sichtbare Vorschaubild zählte auf dasselbe Limit von 300/min. Danach
blieben Bilder leer und "Einstellungen konnten nicht gespeichert werden"
erschien.

Backend: Medien-Reads bekommen einen eigenen Limiter (media, 1200/min),
POST /client-logs nimmt Warnungen und Fehler der App an (client-logs,
30/min) und schreibt sie in den täglichen Kanal `client`; Exceptions
tragen user_id, path und method im Kontext.

Frontend: Einstellungen gehen frühestens 1,5 s nach der letzten Änderung
und nur bei geändertem Inhalt zum Server, 429/Netzfehler werden still mit
Backoff wiederholt, nur eine Ablehnung wird gemeldet. Bilder laufen durch
ein Gate (6 parallel, Retry-After, 3 Versuche) und fallen auf einen
Platzhalter statt auf einen kaputten Pfad zurück. Neuer clientLog-Service
mit IndexedDB-Puffer, Dedupe und Batch-Versand, globaler Fehlerfang im
Boot, Protokoll im Entwickler-Tab. Drei pulsierende Punkte statt weißer
Fläche in Timeline-Punkt, Key Image und Medienkacheln.

Dokumentation in documentation/logging.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 09:31:35 +00:00
522deffa1a Hintergründe: 15 kuratierte Bilder statt Testfotos
Elf universelle Hintergründe von grafisch bis fotografisch (Tinte, Goldstaub,
Horizont, Kohle, Leinen, Nebel, Dämmerung, Nachthimmel, Tusche, Papier,
Aquarell) plus vier v2-Fassungen zur Abstimmung, alle 2048² JPEG mit
320er Vorschauen (7 MB statt 48 MB). Auswahl, Reihenfolge und Helligkeit
stehen in src/config/backgrounds.js; der Picker zeigt Name und aktuelle
Wahl, das Logo folgt der Bildhelligkeit, Presets zeigen auf die neuen
Bilder, alte Pfade werden beim Laden auf den Nachfolger umgeschrieben.
Der Service Worker cacht nur die Vorschauen vor.

Werkzeuge zum Neu-Rendern (SVG-Builder, Gemini-Prompts) liegen in
dev/tools/backgrounds, die Sichtungs-Renders bleiben lokal (gitignore).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 09:31:09 +00:00
10251e4f41 Doku und Werkzeuge: Tickets, Audit, Logos, Playwright-MCP
- dev/: Ticketliste, Audit und Vorschläge vom 17.09., Logo-SVGs,
  Lifeline-Bildwerkzeuge.
- AGENTS.md auf den aktuellen Stand (syncQueue statt syncService, kein
  legacy-Ordner mehr).
- .codex/ und __pycache__/ ignoriert; Playwright-MCP in .mcp.json.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 07:00:35 +00:00
83c6c5813d Frontend: Offline-Warteschlange, PWA, Event-Panel und Markenauftritt
Offline
- Alle Schreibzugriffe von API-Nutzern laufen über eine Warteschlange in
  IndexedDB (services/syncQueue.js): FIFO, zusammengefasste Updates,
  Wiederholung bei Netz/Sichtbarkeit/Backoff, 4xx wird als Fehler gemeldet.
- Medien-Uploads liegen mit Bytes in der Queue, erscheinen sofort mit
  Marker und werden nach dem Upload durch das Server-Objekt ersetzt.
- PWA-Modus mit Service Worker für die App-Hülle; API nie gecacht.
  npm run build erzeugt dist/pwa.
- Speicherzustand im Panel: ausstehend / Fehler mit Wiederholen-Knopf,
  Verbindungsanzeige im Header.

Event
- Event entsteht erst mit dem ersten Eintrag; jede Löschung fragt nach.
- Key Image als 4:3-Rahmen, Galerie mit Video, mehrere Sprachnotizen mit
  Notiz, Live-Pegel beim Aufnehmen, WAV-Umwandlung für lokale Aufnahmen.
- Neue Felder: Zeitraum, Kategorie, Personen, Meilenstein; Stimmung als
  kompakte Zeile mit ausklappbarer Punktfarbe.

Oberfläche
- Untere Leiste: Darstellung und Zoom links, Jahres-Pille mittig, Plus rechts.
- Logos und Goldpalette, Login neu, zehn Wellen-Presets mit Vorschau,
  Hintergrundbilder als Kacheln, Kontomenü und Einstellungen bereinigt,
  Escape schließt Ebenen, größere Touch-Ziele.
- Alten Sync-Service und ungenutzte Legacy-Seiten entfernt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 07:00:35 +00:00
b262a6771f Backend: Medien-Typen, Sprachnotizen, Event-Felder und Sync-Härtung
- Event-Medien tragen Typ (image/video/audio), Dauer und Notiz; Videos
  bekommen ein Poster, Audio wird per ffmpeg nach AAC (audio/mp4)
  gewandelt, Originale werden mit Range-Unterstützung gestreamt.
- Upload nimmt eine Notiz (caption) direkt mit, PATCH ändert sie später.
- Neue Event-Felder: Zeitraum (end_date), Kategorie, Personen, Meilenstein.
  Regeln zentral in EventFields, damit CRUD und Sync gleich prüfen.
- Titel darf leer sein: ein Event entsteht mit dem ersten Eintrag.
- Artisan-Befehl media:transcode-voice-notes wandelt alte WebM/Ogg-Notizen,
  die Safari nicht abspielen kann.
- Login-Throttling, Settings-Validierung, Sync-Fehlercodes ohne Rohtext.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 07:00:35 +00:00
223 changed files with 30380 additions and 8829 deletions

7
.gitignore vendored
View file

@ -53,6 +53,13 @@ Icon
# Project specific
# Local agent/tool configs (may hold API keys)
.codex/
__pycache__/
_static/
_work/
_storage/
# Background review renders (large); README and overview are versioned
dev/backgrounds/*
!dev/backgrounds/README.md
!dev/backgrounds/uebersicht.jpg

View file

@ -22,6 +22,9 @@
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
},
"playwright": {
"url": "https://playwright.test/mcp"
}
}
}

85
AGENTS.md Normal file
View file

@ -0,0 +1,85 @@
# Repository guidance
## Scope and sources
This file applies to the repository. Follow `backend/AGENTS.md` for additional
Laravel-specific instructions when working under `backend/`.
Check neighboring code before changing conventions. Prefer current source,
package manifests, and `docker-compose.yml` over older setup examples; some
README and architecture documents contain placeholders or describe planned work.
## Project map
- `frontend/`: Quasar 2 SPA using Vue 3, Pinia, Vue Router, Vite, and Dexie.
The active experience uses `src/pages/LifeWavePage.vue`,
`src/layouts/LifeWaveLayout.vue`, and components such as `TimelineView.vue`,
`FloatingLines.vue`, and `EventPanel.vue`.
- `frontend/src/stores/`: authentication, settings, and event state.
`src/services/apiClient.js` centralizes API requests and bearer tokens;
`src/services/syncQueue.js` holds the offline write queue every remote
write goes through (see `test/vitest/events-offline.spec.js`);
`src/db/index.js` defines versioned IndexedDB schemas.
- `backend/`: Laravel 12 API and website, with Passport authentication,
Livewire/Volt, Flux UI, and Tailwind CSS 4. API routes are in `routes/api.php`;
application code is in `app/`, Blade views in `resources/views/`, and
migrations in `database/migrations/`.
- `dot-line-system/`: separate Vite/TypeScript visualization experiment.
Root `test/` contains HTML animation experiments, not an automated test suite.
- `frontend/dev/db-api-connect/`: API migration plans and implementation log.
Read the relevant documents for changes to persistence or API contracts,
distinguishing planned features from implemented behavior.
## Commands and validation
There is no root package manifest. Run commands from the relevant application.
| Directory | Command | Purpose |
| --- | --- | --- |
| `frontend/` | `npm ci` | Install locked dependencies |
| `frontend/` | `npm run dev` | Start Quasar; this script also runs `npm install` |
| `frontend/` | `npm run lint` | Check JavaScript and Vue source with ESLint |
| `frontend/` | `npm run build` | Build the production SPA |
| `backend/` | `composer install` | Install PHP dependencies |
| `backend/` | `npm ci` | Install backend asset dependencies |
| `backend/` | `composer dev` | Run server, queue listener, logs, and Vite |
| `backend/` | `npm run build` | Build backend assets |
| `backend/` | `php artisan test --compact --filter=TestName` | Run targeted Pest tests |
| `backend/` | `php artisan test --compact` | Run the backend suite |
| `backend/` | `vendor/bin/pint --dirty --format agent` | Format changed PHP per backend guidance |
For frontend code changes, run lint and the production build as appropriate.
`npm test` in `frontend/` is a placeholder that exits successfully without
running tests; do not report it as test coverage. Manually check affected UI
behavior when possible. Backend tests live in `backend/tests/`; API feature
tests cover authentication, events, settings, and media. The PHPUnit config
forces an in-memory SQLite database, while the application uses MySQL.
Report any checks that could not run and the reason.
The root Compose file defines `laravel.test` (PHP 8.4) and `quasar.app` (Node 20).
It relies on an external `proxy` network and shared `global-mysql`,
`global-mailpit`, and `global-redis` services. It does not provision those
services or Traefik. With that infrastructure available, use
`docker compose up -d`; application commands can run through
`docker compose exec laravel.test ...` or `docker compose exec quasar.app ...`.
## Implementation conventions
- Use existing Quasar components and Vue/Pinia patterns. Frontend formatting is
two spaces, single quotes, no semicolons, and a 100-character print width.
Format changed files rather than running a repository-wide formatting pass.
- Router definitions live in `frontend/src/router/routes.js`; Quasar currently
uses hash routing. Keep network access in the existing API client and stores.
The API base can be configured with `VITE_API_BASE`.
- Laravel/MySQL is the intended authoritative store; IndexedDB and localStorage
support caching and offline behavior during the migration. Inspect the current
stores before assuming all writes use the sync queue. Preserve user scoping,
existing cached data, and stable event/client IDs when changing persistence.
- Enforce ownership on the backend for user settings, events, and media.
Preserve authorized media delivery and test cross-user access restrictions.
- Follow backend guidance for typed PHP, Eloquent, Form Requests, Artisan
generators, Pest tests, and Pint. Keep environment lookups in config files.
- Keep `frontend/src/config/appVersion.js`, `frontend/package.json`, and version
documentation consistent. Document data-model and API-contract changes in
the existing DB/API migration documents.
- Keep credentials out of source and logs. Avoid editing dependency directories
or generated build output; retain lockfiles when changing dependencies.

View file

@ -0,0 +1,108 @@
<?php
namespace App\Console\Commands;
use App\Models\Event;
use App\Models\User;
use App\Services\EventMediaStore;
use Database\Seeders\LifelineSeeder;
use Illuminate\Console\Command;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
/**
* Attaches generated key images to the seeded lifeline events.
*
* Images are expected as `<age>-<month>.png|jpg` (zero padded, e.g. 04-07)
* in `storage/app/private/lifeline-images/<handle>/`. Events that already
* have a key image are skipped unless --force is given.
*/
class ImportLifelineImages extends Command
{
protected $signature = 'lifelines:import-images
{handle : Lifeline handle, e.g. bruno}
{--from= : Image directory (default: storage/app/private/lifeline-images/<handle>)}
{--force : Replace key images that are already attached}
{--dry-run : Only report what would be imported}';
protected $description = 'Attach generated key images to seeded lifeline events';
public function handle(EventMediaStore $store): int
{
$handle = (string) $this->argument('handle');
$dataPath = database_path("data/lifelines/{$handle}.json");
$imageDirectory = (string) ($this->option('from') ?: storage_path("app/private/lifeline-images/{$handle}"));
if (! File::exists($dataPath)) {
$this->error("No lifeline data found at {$dataPath}.");
return self::FAILURE;
}
$lifeline = json_decode(File::get($dataPath), true, flags: JSON_THROW_ON_ERROR);
$user = User::query()->where('email', $lifeline['person']['email'])->first();
if ($user === null) {
$this->error("User {$lifeline['person']['email']} is not seeded yet. Run the LifelineSeeder first.");
return self::FAILURE;
}
$imported = $skipped = $missing = 0;
foreach ($lifeline['events'] as $data) {
$slug = sprintf('%02d-%02d', $data['age'], $data['month']);
$imagePath = $this->findImage($imageDirectory, $slug);
if ($imagePath === null) {
$missing++;
continue;
}
$clientId = LifelineSeeder::clientId($lifeline['person']['email'], $data['age'], $data['month'], $data['day'] ?? null);
$event = Event::query()->where('user_id', $user->id)->where('client_id', $clientId)->first();
if ($event === null) {
$this->warn("{$slug}: event not seeded, skipping.");
$missing++;
continue;
}
if ($event->image !== null && ! $this->option('force')) {
$skipped++;
continue;
}
if ($this->option('dry-run')) {
$this->line("{$slug}: would import ".basename($imagePath)." → {$event->title}");
$imported++;
continue;
}
$store->store($event, new UploadedFile($imagePath, basename($imagePath), null, null, true), 'key_image');
$this->line("{$slug}: {$event->title}");
$imported++;
}
$this->info("Imported {$imported}, skipped {$skipped} (already attached), {$missing} without image.");
return self::SUCCESS;
}
private function findImage(string $directory, string $slug): ?string
{
foreach (['png', 'jpg', 'jpeg'] as $extension) {
$path = "{$directory}/{$slug}.{$extension}";
if (File::exists($path)) {
return $path;
}
}
return null;
}
}

View file

@ -0,0 +1,94 @@
<?php
namespace App\Console\Commands;
use App\Models\EventMedia;
use App\Services\EventMediaProbe;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Re-encodes voice notes that are still stored as they were recorded.
*
* Uploads are transcoded to AAC (audio/mp4) since 2026-09-23. Notes from
* before, or notes uploaded while ffmpeg was missing, sit in WebM or Ogg,
* which Safari cannot play. This brings them to the same format.
*/
class TranscodeVoiceNotes extends Command
{
protected $signature = 'media:transcode-voice-notes
{--dry-run : Only report what would be converted}';
protected $description = 'Convert voice notes still stored as WebM/Ogg to AAC so every browser can play them';
public function handle(EventMediaProbe $probe): int
{
$pending = EventMedia::query()
->where('collection', 'voice_note')
->where('mime_type', '!=', 'audio/mp4')
->orderBy('id')
->get();
if ($pending->isEmpty()) {
$this->info('Every voice note is already audio/mp4.');
return self::SUCCESS;
}
$this->info("{$pending->count()} voice note(s) to convert.");
$converted = 0;
$failed = 0;
foreach ($pending as $media) {
$disk = Storage::disk($media->disk);
if (! $disk->exists($media->path)) {
$this->warn("#{$media->id}: file missing ({$media->path}), skipped.");
$failed++;
continue;
}
if ($this->option('dry-run')) {
$this->line("#{$media->id}: {$media->mime_type} → audio/mp4");
continue;
}
$oldPath = $media->path;
$source = $disk->path($oldPath);
$targetPath = preg_replace('/\.[A-Za-z0-9]+$/', '', $oldPath).'.m4a';
$target = $disk->path($targetPath);
if (! $probe->transcodeAudioToM4a($source, $target)) {
$this->error("#{$media->id}: ffmpeg could not convert {$media->path}.");
$failed++;
continue;
}
$media->update([
'path' => $targetPath,
'mime_type' => 'audio/mp4',
'size' => (int) filesize($target),
'duration_ms' => $probe->durationMs($target) ?? $media->duration_ms,
]);
if ($targetPath !== $oldPath) {
$disk->delete($oldPath);
}
$this->line("#{$media->id}: converted.");
$converted++;
}
if ($this->option('dry-run')) {
return self::SUCCESS;
}
$this->info("Converted {$converted}, failed {$failed}.");
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
}

View file

@ -14,17 +14,23 @@ class AuthController extends Controller
{
public function login(LoginRequest $request): JsonResponse
{
$request->ensureIsNotRateLimited();
$credentials = $request->validated();
$user = User::query()
->where('email', $credentials['email'])
->first();
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
$request->hitRateLimiter();
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
$request->clearRateLimiter();
$token = $user->createToken('quasar-app')->accessToken;
return response()->json([

View file

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreClientLogsRequest;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
class ClientLogController extends Controller
{
/**
* Log channel that receives browser-side warnings and errors.
*/
public const CHANNEL = 'client';
/**
* POST /api/client-logs — a batch of warnings and errors collected in the
* browser. Each entry becomes one line in the `client` log channel, tagged
* with the user, the app version and where in the app it happened.
*/
public function store(StoreClientLogsRequest $request): Response
{
$userId = (int) $request->user()->id;
$batch = [
'user_id' => $userId,
'app_version' => $request->validated('appVersion'),
'session_id' => $request->validated('sessionId'),
'user_agent' => $request->validated('userAgent'),
'ip' => $request->ip(),
];
$logger = Log::channel(self::CHANNEL);
foreach ($request->validated('entries') as $entry) {
$logger->log($entry['level'], "[{$entry['scope']}] {$entry['message']}", array_filter([
...$batch,
'url' => $entry['url'] ?? null,
'occurred_at' => $entry['occurredAt'],
'context' => $entry['context'] ?? null,
], fn (mixed $value): bool => $value !== null && $value !== []));
}
return response()->noContent();
}
}

View file

@ -6,31 +6,47 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEventRequest;
use App\Http\Requests\UpdateEventRequest;
use App\Http\Resources\EventResource;
use App\Models\User;
use App\Services\MediaCleaner;
use App\Support\EventFields;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class EventController extends Controller
{
public function __construct(private readonly MediaCleaner $mediaCleaner) {}
/**
* GET /api/events
* Cursor-based pagination by date. Supports ?since=ISO for delta sync.
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = $request->user()->events()->with('media')->orderBy('date');
$validated = $request->validate([
'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
'since' => ['sometimes', 'date'],
'cursor' => ['sometimes', 'string', 'max:500'],
]);
// `date` is not unique, so it cannot carry a cursor on its own: a page
// boundary inside one day would skip the rest of that day. `id` makes
// the ordering total.
$query = $request->user()->events()
->with('media')
->orderBy('date')
->orderBy('id');
// Delta sync: only events updated since a given timestamp
if ($request->has('since')) {
$since = $request->date('since');
$query->where('updated_at', '>', $since);
if (array_key_exists('since', $validated)) {
$query->where('updated_at', '>', $request->date('since'));
}
// Cursor-based pagination (default 50, max 200)
$limit = min((int) $request->input('limit', 50), 200);
return EventResource::collection(
$query->cursorPaginate($limit)
$query->cursorPaginate($validated['limit'] ?? 50)->withQueryString()
);
}
@ -41,13 +57,7 @@ class EventController extends Controller
{
$event = $request->user()->events()->create([
'client_id' => $request->validated('id'),
'title' => $request->validated('title'),
'date' => $request->validated('date'),
'emotion' => $request->validated('emotion'),
'custom_color' => $request->validated('customColor'),
'gradient_preset' => $request->validated('gradientPreset'),
'image' => $request->validated('image'),
'note' => $request->validated('note'),
...EventFields::toColumns($request->validated()),
]);
return (new EventResource($event->load('media')))
@ -76,32 +86,7 @@ class EventController extends Controller
->where('client_id', $clientId)
->firstOrFail();
$data = [];
$validated = $request->validated();
if (isset($validated['title'])) {
$data['title'] = $validated['title'];
}
if (isset($validated['date'])) {
$data['date'] = $validated['date'];
}
if (isset($validated['emotion'])) {
$data['emotion'] = $validated['emotion'];
}
if (array_key_exists('customColor', $validated)) {
$data['custom_color'] = $validated['customColor'];
}
if (array_key_exists('gradientPreset', $validated)) {
$data['gradient_preset'] = $validated['gradientPreset'];
}
if (array_key_exists('image', $validated)) {
$data['image'] = $validated['image'];
}
if (array_key_exists('note', $validated)) {
$data['note'] = $validated['note'];
}
$event->update($data);
$event->update(EventFields::toColumns($request->validated()));
return new EventResource($event->fresh()->load('media'));
}
@ -115,6 +100,7 @@ class EventController extends Controller
->where('client_id', $clientId)
->firstOrFail();
$this->mediaCleaner->purgeEventMedia($event);
$event->delete();
return response()->json(null, 204);
@ -123,6 +109,9 @@ class EventController extends Controller
/**
* POST /api/events/sync
* Batch sync: process multiple mutations in one request.
*
* Every payload runs through the same field rules as the CRUD endpoints,
* so this endpoint cannot be used to write values plain CRUD rejects.
*/
public function sync(Request $request): JsonResponse
{
@ -142,58 +131,97 @@ class EventController extends Controller
$payload = $mutation['payload'] ?? [];
try {
if ($action === 'create') {
$event = $user->events()->where('client_id', $clientId)->first();
if (! $event) {
$event = $user->events()->create([
'client_id' => $clientId,
'title' => $payload['title'] ?? 'Untitled',
'date' => $payload['date'] ?? now()->format('Y-m-d'),
'emotion' => $payload['emotion'] ?? 0,
'custom_color' => $payload['customColor'] ?? null,
'gradient_preset' => $payload['gradientPreset'] ?? null,
'image' => $payload['image'] ?? null,
'note' => $payload['note'] ?? null,
]);
}
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
} elseif ($action === 'update') {
$event = $user->events()->where('client_id', $clientId)->first();
if ($event) {
$data = [];
if (isset($payload['title'])) {
$data['title'] = $payload['title'];
}
if (isset($payload['date'])) {
$data['date'] = $payload['date'];
}
if (isset($payload['emotion'])) {
$data['emotion'] = $payload['emotion'];
}
if (array_key_exists('customColor', $payload)) {
$data['custom_color'] = $payload['customColor'];
}
if (array_key_exists('gradientPreset', $payload)) {
$data['gradient_preset'] = $payload['gradientPreset'];
}
if (array_key_exists('image', $payload)) {
$data['image'] = $payload['image'];
}
if (array_key_exists('note', $payload)) {
$data['note'] = $payload['note'];
}
$event->update($data);
}
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
} elseif ($action === 'delete') {
$user->events()->where('client_id', $clientId)->delete();
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
}
$results[] = match ($action) {
'create' => $this->syncCreate($user, $clientId, $payload),
'update' => $this->syncUpdate($user, $clientId, $payload),
'delete' => $this->syncDelete($user, $clientId),
};
} catch (ValidationException $e) {
$results[] = [
'eventId' => $clientId,
'status' => 'invalid',
'errors' => $e->errors(),
];
} catch (\Throwable $e) {
$results[] = ['eventId' => $clientId, 'status' => 'error', 'message' => $e->getMessage()];
// Technical detail stays in the log; the client gets a stable
// code instead of a raw SQL/schema message.
Log::error('Event sync mutation failed', [
'userId' => $user->id,
'action' => $action,
'eventId' => $clientId,
'exception' => $e,
]);
$results[] = [
'eventId' => $clientId,
'status' => 'error',
'code' => 'sync_failed',
];
}
}
return response()->json(['results' => $results]);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function syncCreate(User $user, string $clientId, array $payload): array
{
$existing = $user->events()->where('client_id', $clientId)->first();
if ($existing) {
// Create is idempotent: replaying a queued mutation must not
// duplicate or silently overwrite the stored event.
return ['eventId' => $clientId, 'status' => 'exists'];
}
$validated = Validator::make($payload, EventFields::storeRules())->validate();
$user->events()->create([
'client_id' => $clientId,
...EventFields::toColumns($validated),
]);
return ['eventId' => $clientId, 'status' => 'ok'];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function syncUpdate(User $user, string $clientId, array $payload): array
{
$event = $user->events()->where('client_id', $clientId)->first();
if (! $event) {
// Previously reported as 'ok', which let the client drop a
// mutation that never reached the database.
return ['eventId' => $clientId, 'status' => 'not_found'];
}
$validated = Validator::make($payload, EventFields::updateRules())->validate();
$event->update(EventFields::toColumns($validated));
return ['eventId' => $clientId, 'status' => 'ok'];
}
/**
* @return array<string, mixed>
*/
private function syncDelete(User $user, string $clientId): array
{
$event = $user->events()->where('client_id', $clientId)->first();
if (! $event) {
return ['eventId' => $clientId, 'status' => 'not_found'];
}
$this->mediaCleaner->purgeEventMedia($event);
$event->delete();
return ['eventId' => $clientId, 'status' => 'ok'];
}
}

View file

@ -4,16 +4,21 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEventMediaRequest;
use App\Http\Requests\UpdateEventMediaRequest;
use App\Http\Resources\EventMediaResource;
use App\Models\EventMedia;
use App\Services\EventMediaImageProcessor;
use App\Services\EventMediaStore;
use App\Services\MediaCleaner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class EventMediaController extends Controller
{
public function __construct(private readonly MediaCleaner $mediaCleaner) {}
public function index(Request $request, string $clientId): JsonResponse
{
$event = $request->user()->events()
@ -29,58 +34,26 @@ class EventMediaController extends Controller
public function store(
StoreEventMediaRequest $request,
EventMediaImageProcessor $processor,
EventMediaStore $store,
string $clientId
): JsonResponse {
$event = $request->user()->events()
->where('client_id', $clientId)
->firstOrFail();
$collection = $request->validated('collection') ?? 'gallery';
$processed = $processor->process($request->file('file'));
$uuid = (string) str()->uuid();
$directory = "event-media/{$request->user()->id}/{$event->client_id}";
$path = "{$directory}/{$uuid}.jpg";
$previewPath = "{$directory}/{$uuid}_preview.jpg";
$thumbnailPath = "{$directory}/{$uuid}_thumb.jpg";
Storage::disk('local')->put($path, $processed['original']);
Storage::disk('local')->put($previewPath, $processed['preview']);
Storage::disk('local')->put($thumbnailPath, $processed['thumbnail']);
if ($collection === 'key_image') {
$this->deleteExistingKeyImages($event->media()->where('collection', 'key_image')->get());
}
$media = $event->media()->create([
'uuid' => $uuid,
'user_id' => $request->user()->id,
'collection' => $collection,
'name' => $request->file('file')->getClientOriginalName(),
'mime_type' => $processed['mime_type'],
'disk' => 'local',
'path' => $path,
'thumbnail_path' => $thumbnailPath,
'preview_path' => $previewPath,
'size' => $processed['size'],
'width' => $processed['width'],
'height' => $processed['height'],
'thumbnail_width' => $processed['thumbnail_width'],
'thumbnail_height' => $processed['thumbnail_height'],
'preview_width' => $processed['preview_width'],
'preview_height' => $processed['preview_height'],
]);
if ($collection === 'key_image') {
$event->update(['image' => "/event-media/{$media->id}/thumb"]);
}
$media = $store->store(
$event,
$request->file('file'),
$request->validated('collection') ?? 'gallery',
$request->validated('caption'),
);
return (new EventMediaResource($media))
->response()
->setStatusCode(201);
}
public function show(Request $request, EventMedia $media, string $variant): Response
public function show(Request $request, EventMedia $media, string $variant): Response|BinaryFileResponse
{
abort_unless(in_array($variant, ['thumb', 'preview', 'original'], true), 404);
abort_unless((int) $media->user_id === (int) $request->user()->id, 404);
@ -94,12 +67,41 @@ class EventMediaController extends Controller
abort_unless(is_string($path) && $path !== '', 404);
abort_unless(Storage::disk($media->disk)->exists($path), 404);
return response(Storage::disk($media->disk)->get($path), 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=604800',
$headers = ['Cache-Control' => 'private, max-age=604800'];
if ($variant !== 'original') {
// Thumbnails and previews are always JPEG, whatever the original.
return response(Storage::disk($media->disk)->get($path), 200, [
...$headers,
'Content-Type' => 'image/jpeg',
]);
}
// Originals may be videos or audio: a file response streams them and
// honours Range requests, which media players need for seeking.
return response()->file(Storage::disk($media->disk)->path($path), [
...$headers,
'Content-Type' => $media->mime_type ?: 'application/octet-stream',
]);
}
/**
* PATCH /api/events/{clientId}/media/{media} — the caption only; the
* file itself is immutable.
*/
public function update(UpdateEventMediaRequest $request, string $clientId, EventMedia $media): EventMediaResource
{
$event = $request->user()->events()
->where('client_id', $clientId)
->firstOrFail();
abort_unless((int) $media->event_id === (int) $event->id, 404);
$media->update(['caption' => $request->validated('caption') ?? '']);
return new EventMediaResource($media->fresh());
}
public function destroy(Request $request, string $clientId, EventMedia $media): JsonResponse
{
$event = $request->user()->events()
@ -109,7 +111,7 @@ class EventMediaController extends Controller
abort_unless((int) $media->event_id === (int) $event->id, 404);
$wasKeyImage = $media->collection === 'key_image';
$this->deleteMediaFiles($media);
$this->mediaCleaner->purgeMedia($media);
$media->delete();
if ($wasKeyImage) {
@ -118,21 +120,4 @@ class EventMediaController extends Controller
return response()->json(null, 204);
}
private function deleteExistingKeyImages(iterable $mediaItems): void
{
foreach ($mediaItems as $media) {
$this->deleteMediaFiles($media);
$media->delete();
}
}
private function deleteMediaFiles(EventMedia $media): void
{
Storage::disk($media->disk)->delete(array_filter([
$media->path,
$media->preview_path,
$media->thumbnail_path,
]));
}
}

View file

@ -3,9 +3,22 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Failed login attempts allowed per throttle key before locking out.
*/
private const MAX_ATTEMPTS = 5;
/**
* Lockout duration in seconds once the attempt limit is exceeded.
*/
private const DECAY_SECONDS = 60;
public function authorize(): bool
{
return true;
@ -23,4 +36,53 @@ class LoginRequest extends FormRequest
'password' => ['required', 'string'],
];
}
/**
* Reject the request once too many failed attempts came from this
* identity/IP pair, before any password hash is ever computed.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), self::MAX_ATTEMPTS)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
])->status(429);
}
/**
* Record a failed attempt against this identity/IP pair.
*/
public function hitRateLimiter(): void
{
RateLimiter::hit($this->throttleKey(), self::DECAY_SECONDS);
}
/**
* Clear the counter after a successful login.
*/
public function clearRateLimiter(): void
{
RateLimiter::clear($this->throttleKey());
}
/**
* Throttle per normalised e-mail *and* IP, so one attacker cannot lock
* out an unrelated account and one IP cannot cycle through accounts.
*/
public function throttleKey(): string
{
return Str::transliterate(
'api-login|'.Str::lower((string) $this->input('email')).'|'.$this->ip()
);
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests;
use App\Rules\ScalarSetting;
use Illuminate\Foundation\Http\FormRequest;
class StoreClientLogsRequest extends FormRequest
{
/**
* Levels the client may report. Anything below a warning stays in the
* browser; the server log is for things somebody has to look at.
*
* @var array<int, string>
*/
public const LEVELS = ['warning', 'error'];
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'appVersion' => ['nullable', 'string', 'max:40'],
'sessionId' => ['nullable', 'string', 'max:64'],
'userAgent' => ['nullable', 'string', 'max:400'],
'entries' => ['required', 'array', 'min:1', 'max:50'],
'entries.*.level' => ['required', 'string', 'in:'.implode(',', self::LEVELS)],
'entries.*.scope' => ['required', 'string', 'max:60'],
'entries.*.message' => ['required', 'string', 'max:1000'],
'entries.*.url' => ['nullable', 'string', 'max:500'],
'entries.*.occurredAt' => ['required', 'date'],
'entries.*.context' => ['nullable', 'array', 'max:20'],
// Context values are flat: a status code, a path, a message. Nested
// structures are stringified by the client before sending.
'entries.*.context.*' => ['nullable', new ScalarSetting(500)],
];
}
}

View file

@ -6,6 +6,19 @@ use Illuminate\Foundation\Http\FormRequest;
class StoreEventMediaRequest extends FormRequest
{
/** Images accepted everywhere an image is allowed. */
private const IMAGE_MIMES = 'jpg,jpeg,png,webp';
/** Container formats phones and browsers actually produce. */
private const VIDEO_MIMETYPES = 'video/mp4,video/quicktime,video/webm,video/x-m4v';
/**
* MediaRecorder output differs per browser: Chrome and Firefox record
* audio into a WebM container, which libmagic reports as video/webm;
* Safari records audio/mp4 (often sniffed as audio/x-m4a or video/mp4).
*/
private const AUDIO_MIMETYPES = 'audio/webm,video/webm,audio/ogg,application/ogg,audio/mp4,audio/x-m4a,video/mp4,audio/mpeg,audio/wav,audio/x-wav,audio/aac';
public function authorize(): bool
{
return true;
@ -14,13 +27,44 @@ class StoreEventMediaRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* The accepted file type depends on the collection: the key image stays
* an image, the gallery takes images and videos, and a voice note is
* audio only.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$file = match ($this->input('collection', 'gallery')) {
'voice_note' => ['required', 'file', 'mimetypes:'.self::AUDIO_MIMETYPES, 'max:20480'],
'gallery' => [
'required',
'file',
'mimetypes:image/jpeg,image/png,image/webp,'.self::VIDEO_MIMETYPES,
'max:102400',
],
default => ['required', 'image', 'mimes:'.self::IMAGE_MIMES, 'max:10240'],
};
return [
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
'collection' => ['nullable', 'in:key_image,gallery'],
'file' => $file,
'collection' => ['nullable', 'in:key_image,gallery,voice_note'],
// Sent along with the file when the note was written before the
// upload could happen (offline), so it needs no second request.
'caption' => ['nullable', 'string', 'max:500'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'file.mimetypes' => 'Dieses Dateiformat wird hier nicht unterstützt.',
'file.mimes' => 'Dieses Dateiformat wird hier nicht unterstützt.',
'file.image' => 'Für das Key Image ist nur ein Bild erlaubt.',
'file.max' => 'Die Datei ist zu groß.',
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Support\EventFields;
use Illuminate\Foundation\Http\FormRequest;
class StoreEventRequest extends FormRequest
@ -15,13 +16,7 @@ class StoreEventRequest extends FormRequest
{
return [
'id' => ['required', 'uuid', 'unique:events,client_id'],
'title' => ['required', 'string', 'max:255'],
'date' => ['required', 'date_format:Y-m-d'],
'emotion' => ['required', 'numeric', 'min:-1', 'max:1'],
'customColor' => ['nullable', 'string', 'max:20'],
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
'image' => ['nullable', 'string', 'max:500'],
'note' => ['nullable', 'string', 'max:5000'],
...EventFields::storeRules(),
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEventMediaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'caption' => ['present', 'nullable', 'string', 'max:500'],
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Support\EventFields;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEventRequest extends FormRequest
@ -13,14 +14,6 @@ class UpdateEventRequest extends FormRequest
public function rules(): array
{
return [
'title' => ['sometimes', 'required', 'string', 'max:255'],
'date' => ['sometimes', 'required', 'date_format:Y-m-d'],
'emotion' => ['sometimes', 'required', 'numeric', 'min:-1', 'max:1'],
'customColor' => ['nullable', 'string', 'max:20'],
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
'image' => ['nullable', 'string', 'max:500'],
'note' => ['nullable', 'string', 'max:5000'],
];
return EventFields::updateRules();
}
}

View file

@ -2,7 +2,9 @@
namespace App\Http\Requests;
use App\Support\SettingsSchema;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class UpdateSettingsRequest extends FormRequest
{
@ -17,21 +19,59 @@ class UpdateSettingsRequest extends FormRequest
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return SettingsSchema::rules();
}
/**
* Reject keys the schema does not know about instead of persisting them.
*
* @return array<int, callable>
*/
public function after(): array
{
return [
'settings' => ['required', 'array'],
'settings.theme' => ['nullable', 'string', 'max:50'],
'settings.floatingLines' => ['nullable', 'array'],
'settings.appearance' => ['nullable', 'string', 'max:50'],
'settings.accentColor' => ['nullable', 'string', 'max:50'],
'settings.language' => ['nullable', 'string', 'max:10'],
'settings.emotionGradientStart' => ['nullable', 'string', 'max:20'],
'settings.emotionGradientEnd' => ['nullable', 'string', 'max:20'],
'settings.timelineZoom' => ['nullable', 'numeric', 'min:0.1', 'max:10'],
'settings.timelineScrollLeft' => ['nullable', 'numeric', 'min:0'],
'settings.showFps' => ['nullable', 'boolean'],
'settings.presets' => ['nullable', 'array'],
'settings.activePresetId' => ['nullable', 'string', 'max:100'],
function (Validator $validator): void {
$this->rejectUnknownKeys(
$validator,
'settings',
(array) $this->input('settings', []),
SettingsSchema::allowedKeys(),
);
$floatingLines = $this->input('settings.floatingLines');
if (is_array($floatingLines)) {
$this->rejectUnknownKeys(
$validator,
'settings.floatingLines',
$floatingLines,
SettingsSchema::allowedFloatingLineKeys(),
);
}
},
];
}
/**
* @param array<string, mixed> $given
* @param array<int, string> $allowed
*/
private function rejectUnknownKeys(
Validator $validator,
string $attribute,
array $given,
array $allowed,
): void {
$unknown = array_diff(array_keys($given), $allowed);
if ($unknown === []) {
return;
}
$validator->errors()->add(
$attribute,
'Unknown settings keys: '.implode(', ', array_map('strval', $unknown)).'.',
);
}
}

View file

@ -9,24 +9,32 @@ class EventMediaResource extends JsonResource
{
public function toArray(Request $request): array
{
$base = "/event-media/{$this->id}";
$hasThumbnail = is_string($this->thumbnail_path) && $this->thumbnail_path !== '';
$hasPreview = $hasThumbnail || (is_string($this->preview_path) && $this->preview_path !== '');
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
'type' => 'image',
'type' => $this->type ?? 'image',
'collection' => $this->collection,
'name' => $this->name,
'caption' => $this->caption ?? '',
'mimeType' => $this->mime_type,
'size' => $this->size,
'durationMs' => $this->duration_ms,
'width' => $this->width,
'height' => $this->height,
'thumbnailWidth' => $this->thumbnail_width,
'thumbnailHeight' => $this->thumbnail_height,
'previewWidth' => $this->preview_width,
'previewHeight' => $this->preview_height,
'src' => "/event-media/{$this->id}/thumb",
'thumbnailUrl' => "/event-media/{$this->id}/thumb",
'previewUrl' => "/event-media/{$this->id}/preview",
'originalUrl' => "/event-media/{$this->id}/original",
// Audio has no image variants; a video without an extracted
// poster frame has none either. The client shows a placeholder.
'src' => $hasThumbnail ? "{$base}/thumb" : null,
'thumbnailUrl' => $hasThumbnail ? "{$base}/thumb" : null,
'previewUrl' => $hasPreview ? "{$base}/preview" : null,
'originalUrl' => "{$base}/original",
'createdAt' => $this->created_at->getTimestampMs(),
];
}

View file

@ -12,11 +12,17 @@ class EventResource extends JsonResource
return [
'id' => $this->client_id,
'title' => $this->title,
'date' => $this->date->format('Y-m-d'),
'date' => substr((string) $this->date, 0, 10),
'endDate' => $this->end_date ? substr((string) $this->end_date, 0, 10) : null,
'location' => $this->location ?? '',
'category' => $this->category,
'people' => $this->people ?? [],
'emotion' => (float) $this->emotion,
'milestone' => (bool) $this->milestone,
'customColor' => $this->custom_color,
'gradientPreset' => $this->gradient_preset,
'image' => $this->image,
'keyImageTitle' => $this->key_image_title ?? '',
'note' => $this->note ?? '',
'media' => EventMediaResource::collection($this->whenLoaded('media')),
'syncStatus' => 'synced',

View file

@ -16,19 +16,31 @@ class Event extends Model
'client_id',
'title',
'date',
'end_date',
'location',
'category',
'people',
'emotion',
'milestone',
'custom_color',
'gradient_preset',
'image',
'key_image_title',
'note',
];
protected function casts(): array
{
return [
'date' => 'date:Y-m-d',
// `date` is a pure calendar date and is deliberately NOT cast to a
// Carbon instance: a casted value is bound into cursor-pagination
// queries as "Y-m-d H:i:s", which never matches the "Y-m-d" the
// DATE column holds, and it drags a timezone into a value that has
// none. It stays the raw "Y-m-d" string.
'emotion' => 'decimal:3',
'gradient_preset' => 'integer',
'people' => 'array',
'milestone' => 'boolean',
];
}

View file

@ -17,13 +17,16 @@ class EventMedia extends Model
'user_id',
'event_id',
'collection',
'type',
'name',
'caption',
'mime_type',
'disk',
'path',
'thumbnail_path',
'preview_path',
'size',
'duration_ms',
'width',
'height',
'thumbnail_width',
@ -36,6 +39,7 @@ class EventMedia extends Model
{
return [
'size' => 'integer',
'duration_ms' => 'integer',
'width' => 'integer',
'height' => 'integer',
'thumbnail_width' => 'integer',

View file

@ -2,6 +2,9 @@
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -19,6 +22,34 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
$this->configureRateLimiting();
}
/**
* Rate limiters for the API surface consumed by the Quasar app.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request): Limit {
return $request->user()
? Limit::perMinute(300)->by('api|user|'.$request->user()->getAuthIdentifier())
: Limit::perMinute(60)->by('api|ip|'.$request->ip());
});
RateLimiter::for('uploads', function (Request $request): Limit {
return Limit::perMinute(30)->by('uploads|user|'.$request->user()?->getAuthIdentifier());
});
// Thumbnails, previews and originals. One request per image the
// timeline shows, so this has to be far above the write budget.
RateLimiter::for('media', function (Request $request): Limit {
return Limit::perMinute(1200)->by('media|user|'.$request->user()?->getAuthIdentifier());
});
// Client-side log batches; the app sends at most one every few
// seconds, so anything beyond this is a misbehaving client.
RateLimiter::for('client-logs', function (Request $request): Limit {
return Limit::perMinute(30)->by('client-logs|user|'.$request->user()?->getAuthIdentifier());
});
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Allows only bounded scalar values inside the settings document, so a
* client cannot nest arrays or objects where a shader uniform or a colour
* string is expected.
*/
class ScalarSetting implements ValidationRule
{
public function __construct(private readonly int $maxLength = 4096) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || is_bool($value) || is_int($value) || is_float($value)) {
return;
}
if (! is_string($value)) {
$fail('The :attribute must be a string, number or boolean.');
return;
}
if (mb_strlen($value) > $this->maxLength) {
$fail("The :attribute may not be greater than {$this->maxLength} characters.");
}
}
}

View file

@ -0,0 +1,134 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;
/**
* Reads the duration of an audio or video file and extracts a poster frame
* from a video, using ffprobe / ffmpeg when they are installed.
*
* Both are optional: without them a video is stored without a poster and a
* recording without a duration. The upload itself never fails because of a
* missing tool.
*/
class EventMediaProbe
{
private const TIMEOUT_SECONDS = 30;
public function durationMs(string $path): ?int
{
$ffprobe = $this->binary('ffprobe');
if ($ffprobe === null) {
return null;
}
$process = new Process([
$ffprobe,
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
$path,
]);
$process->setTimeout(self::TIMEOUT_SECONDS);
try {
$process->run();
} catch (\Throwable $e) {
Log::info('ffprobe failed', ['path' => $path, 'exception' => $e->getMessage()]);
return null;
}
if (! $process->isSuccessful()) {
return null;
}
$seconds = (float) trim($process->getOutput());
return $seconds > 0 ? (int) round($seconds * 1000) : null;
}
/**
* Write a JPEG poster frame of the video to $targetPath.
*
* @return bool true when the frame was written
*/
public function posterFrame(string $videoPath, string $targetPath, ?int $durationMs = null): bool
{
$ffmpeg = $this->binary('ffmpeg');
if ($ffmpeg === null) {
return false;
}
// A frame a little into the clip avoids the black first frame many
// recordings start with; very short clips take the first frame.
$seek = $durationMs !== null && $durationMs > 2000 ? '0.5' : '0';
$process = new Process([
$ffmpeg,
'-v', 'error',
'-y',
'-ss', $seek,
'-i', $videoPath,
'-frames:v', '1',
'-q:v', '3',
$targetPath,
]);
$process->setTimeout(self::TIMEOUT_SECONDS);
try {
$process->run();
} catch (\Throwable $e) {
Log::info('ffmpeg poster extraction failed', ['path' => $videoPath, 'exception' => $e->getMessage()]);
return false;
}
return $process->isSuccessful() && is_file($targetPath) && filesize($targetPath) > 0;
}
/**
* Re-encode an audio recording as AAC in an MP4 container.
*
* @return bool true when the file was written
*/
public function transcodeAudioToM4a(string $sourcePath, string $targetPath): bool
{
$ffmpeg = $this->binary('ffmpeg');
if ($ffmpeg === null) {
return false;
}
$process = new Process([
$ffmpeg,
'-v', 'error',
'-y',
'-i', $sourcePath,
'-vn',
'-c:a', 'aac',
'-b:a', '96k',
'-movflags', '+faststart',
'-f', 'mp4',
$targetPath,
]);
$process->setTimeout(self::TIMEOUT_SECONDS * 4);
try {
$process->run();
} catch (\Throwable $e) {
Log::info('ffmpeg audio transcode failed', ['path' => $sourcePath, 'exception' => $e->getMessage()]);
return false;
}
return $process->isSuccessful() && is_file($targetPath) && filesize($targetPath) > 0;
}
private function binary(string $name): ?string
{
return (new ExecutableFinder)->find($name);
}
}

View file

@ -0,0 +1,283 @@
<?php
namespace App\Services;
use App\Models\Event;
use App\Models\EventMedia;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Stores a file as event media and creates the row.
*
* Images get an original, a preview and a thumbnail. Videos keep their
* original file and get a poster frame (preview + thumbnail) when ffmpeg is
* available. Voice notes are transcoded to AAC in an MP4 container when
* ffmpeg is available, because browser recordings (WebM/Opus) lack duration
* and index data and do not play in Safari at all. An event can carry any
* number of voice notes. The key image points the event at the new
* thumbnail. Shared by the API upload and the lifeline importer.
*/
class EventMediaStore
{
public const TYPE_IMAGE = 'image';
public const TYPE_VIDEO = 'video';
public const TYPE_AUDIO = 'audio';
public function __construct(
private readonly EventMediaImageProcessor $processor,
private readonly EventMediaProbe $probe,
private readonly MediaCleaner $mediaCleaner,
) {}
public function store(Event $event, UploadedFile $file, string $collection = 'gallery', ?string $caption = null): EventMedia
{
$type = $this->typeFor($file, $collection);
$uuid = (string) Str::uuid();
$directory = "event-media/{$event->user_id}/{$event->client_id}";
$attributes = match ($type) {
self::TYPE_IMAGE => $this->storeImage($file, $directory, $uuid),
self::TYPE_VIDEO => $this->storeVideo($file, $directory, $uuid),
default => $this->storeAudio($file, $directory, $uuid),
};
if ($collection === 'key_image') {
$this->deleteExistingInCollection($event, 'key_image');
}
$media = $event->media()->create([
'uuid' => $uuid,
'user_id' => $event->user_id,
'collection' => $collection,
'type' => $type,
'caption' => $caption !== null && $caption !== '' ? $caption : null,
'name' => $file->getClientOriginalName(),
'disk' => 'local',
...$attributes,
]);
if ($collection === 'key_image') {
$event->update(['image' => "/event-media/{$media->id}/thumb"]);
}
return $media;
}
public function typeFor(UploadedFile $file, string $collection): string
{
if ($collection === 'voice_note') {
return self::TYPE_AUDIO;
}
$mime = (string) ($file->getMimeType() ?: $file->getClientMimeType());
return str_starts_with($mime, 'video/') ? self::TYPE_VIDEO : self::TYPE_IMAGE;
}
/**
* @return array<string, mixed>
*/
private function storeImage(UploadedFile $file, string $directory, string $uuid): array
{
$processed = $this->processor->process($file);
$path = "{$directory}/{$uuid}.jpg";
$previewPath = "{$directory}/{$uuid}_preview.jpg";
$thumbnailPath = "{$directory}/{$uuid}_thumb.jpg";
Storage::disk('local')->put($path, $processed['original']);
Storage::disk('local')->put($previewPath, $processed['preview']);
Storage::disk('local')->put($thumbnailPath, $processed['thumbnail']);
return [
'mime_type' => $processed['mime_type'],
'path' => $path,
'thumbnail_path' => $thumbnailPath,
'preview_path' => $previewPath,
'size' => $processed['size'],
'duration_ms' => null,
'width' => $processed['width'],
'height' => $processed['height'],
'thumbnail_width' => $processed['thumbnail_width'],
'thumbnail_height' => $processed['thumbnail_height'],
'preview_width' => $processed['preview_width'],
'preview_height' => $processed['preview_height'],
];
}
/**
* @return array<string, mixed>
*/
private function storeVideo(UploadedFile $file, string $directory, string $uuid): array
{
$extension = $this->extensionFor($file, 'mp4');
$path = "{$directory}/{$uuid}.{$extension}";
$realPath = (string) $file->getRealPath();
Storage::disk('local')->putFileAs($directory, $file, "{$uuid}.{$extension}");
$durationMs = $this->probe->durationMs($realPath);
$poster = $this->posterFor($realPath, $durationMs);
$attributes = [
'mime_type' => $file->getMimeType() ?: ($file->getClientMimeType() ?: 'video/mp4'),
'path' => $path,
'thumbnail_path' => null,
'preview_path' => null,
'size' => $file->getSize(),
'duration_ms' => $durationMs,
'width' => null,
'height' => null,
'thumbnail_width' => null,
'thumbnail_height' => null,
'preview_width' => null,
'preview_height' => null,
];
if ($poster !== null) {
$previewPath = "{$directory}/{$uuid}_preview.jpg";
$thumbnailPath = "{$directory}/{$uuid}_thumb.jpg";
Storage::disk('local')->put($previewPath, $poster['preview']);
Storage::disk('local')->put($thumbnailPath, $poster['thumbnail']);
$attributes = [
...$attributes,
'thumbnail_path' => $thumbnailPath,
'preview_path' => $previewPath,
'width' => $poster['width'],
'height' => $poster['height'],
'thumbnail_width' => $poster['thumbnail_width'],
'thumbnail_height' => $poster['thumbnail_height'],
'preview_width' => $poster['preview_width'],
'preview_height' => $poster['preview_height'],
];
}
return $attributes;
}
/**
* @return array<string, mixed>
*/
private function storeAudio(UploadedFile $file, string $directory, string $uuid): array
{
$transcoded = $this->transcodedAudio($file);
if ($transcoded !== null) {
$path = "{$directory}/{$uuid}.m4a";
Storage::disk('local')->put($path, (string) file_get_contents($transcoded));
$durationMs = $this->probe->durationMs($transcoded);
$size = (int) filesize($transcoded);
@unlink($transcoded);
return $this->audioAttributes($path, 'audio/mp4', $size, $durationMs);
}
// Without ffmpeg the recording is kept as delivered.
$extension = $this->extensionFor($file, 'webm');
$path = "{$directory}/{$uuid}.{$extension}";
Storage::disk('local')->putFileAs($directory, $file, "{$uuid}.{$extension}");
// Browsers record audio into containers libmagic reports as video
// (WebM, MP4). The client's own type is the honest one here.
$clientMime = (string) $file->getClientMimeType();
$mime = str_starts_with($clientMime, 'audio/')
? $clientMime
: ($file->getMimeType() ?: 'audio/webm');
return $this->audioAttributes(
$path,
$mime,
(int) $file->getSize(),
$this->probe->durationMs((string) $file->getRealPath()),
);
}
/**
* Path of a temporary AAC/MP4 rendition of the upload, or null when
* ffmpeg is unavailable or the input could not be decoded.
*/
private function transcodedAudio(UploadedFile $file): ?string
{
$target = tempnam(sys_get_temp_dir(), 'voice_').'.m4a';
if ($this->probe->transcodeAudioToM4a((string) $file->getRealPath(), $target)) {
@unlink(substr($target, 0, -4));
return $target;
}
@unlink($target);
@unlink(substr($target, 0, -4));
return null;
}
/**
* @return array<string, mixed>
*/
private function audioAttributes(string $path, string $mime, int $size, ?int $durationMs): array
{
return [
'mime_type' => $mime,
'path' => $path,
'thumbnail_path' => null,
'preview_path' => null,
'size' => $size,
'duration_ms' => $durationMs,
'width' => null,
'height' => null,
'thumbnail_width' => null,
'thumbnail_height' => null,
'preview_width' => null,
'preview_height' => null,
];
}
/**
* Poster frame of a video, run through the image pipeline so it gets the
* same preview and thumbnail sizes as an uploaded photo.
*
* @return array<string, mixed>|null
*/
private function posterFor(string $videoPath, ?int $durationMs): ?array
{
$posterPath = tempnam(sys_get_temp_dir(), 'poster_').'.jpg';
try {
if (! $this->probe->posterFrame($videoPath, $posterPath, $durationMs)) {
return null;
}
$poster = new UploadedFile($posterPath, 'poster.jpg', 'image/jpeg', null, true);
return $this->processor->process($poster);
} catch (\Throwable) {
return null;
} finally {
@unlink($posterPath);
@unlink(substr($posterPath, 0, -4));
}
}
private function extensionFor(UploadedFile $file, string $fallback): string
{
$extension = strtolower((string) ($file->getClientOriginalExtension() ?: $file->guessExtension()));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?? '';
return $extension !== '' ? $extension : $fallback;
}
private function deleteExistingInCollection(Event $event, string $collection): void
{
foreach ($event->media()->where('collection', $collection)->get() as $media) {
$this->mediaCleaner->purgeMedia($media);
$media->delete();
}
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace App\Services;
use App\Models\Event;
use App\Models\EventMedia;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* Removes media files for every deletion path.
*
* Database rows disappear on their own through `cascadeOnDelete`, but the
* files behind them do not. Paths are therefore collected *before* the rows
* are deleted, and each deletion path routes through this service.
*/
class MediaCleaner
{
/**
* Delete the files of an event's media. The rows are left to the
* caller / database cascade.
*/
public function purgeEventMedia(Event $event): void
{
$this->deleteFiles($event->media()->get());
}
/**
* Delete the files of every media row belonging to a user, plus the
* user's settings background.
*/
public function purgeUserMedia(User $user): void
{
$this->deleteFiles(EventMedia::query()->where('user_id', $user->id)->get());
$this->delete('local', ["settings-media/{$user->id}/background.jpg"]);
}
/**
* Delete the files of a single media row.
*/
public function purgeMedia(EventMedia $media): void
{
$this->deleteFiles([$media]);
}
/**
* @param iterable<int, EventMedia> $mediaItems
*/
private function deleteFiles(iterable $mediaItems): void
{
$pathsByDisk = [];
foreach ($mediaItems as $media) {
$disk = $media->disk ?: 'local';
foreach ([$media->path, $media->preview_path, $media->thumbnail_path] as $path) {
if (is_string($path) && $path !== '') {
$pathsByDisk[$disk][] = $path;
}
}
}
foreach ($pathsByDisk as $disk => $paths) {
$this->delete($disk, $paths);
}
}
/**
* @param array<int, string> $paths
*/
private function delete(string $disk, array $paths): void
{
if ($paths === []) {
return;
}
try {
Storage::disk($disk)->delete($paths);
} catch (\Throwable $e) {
// A failed file delete must not abort the surrounding deletion —
// an orphaned file is recoverable, a half-deleted account is not.
Log::warning('Media cleanup failed', [
'disk' => $disk,
'paths' => $paths,
'exception' => $e->getMessage(),
]);
}
}
}

View file

@ -0,0 +1,169 @@
<?php
namespace App\Support;
/**
* Single source of truth for event field validation and column mapping.
*
* Both the CRUD endpoints and the batch sync endpoint build their rules from
* here, so a field can never be validated on one path and waved through on
* the other.
*/
final class EventFields
{
/**
* Category keys the client offers. Kept as keys, not labels, so the
* wording can change without touching stored data.
*/
public const CATEGORIES = [
'family', 'friends', 'love', 'work', 'education', 'health',
'travel', 'home', 'hobby', 'loss', 'other',
];
/**
* Validation rules per API field, without any presence rule.
*
* @return array<string, array<int, string>>
*/
public static function rules(): array
{
return [
// Empty is allowed: an event is created with its first entry,
// which may well be a photo or a note rather than a title.
'title' => ['nullable', 'string', 'max:255'],
'date' => ['date_format:Y-m-d'],
// A span ends on or after the day it starts; null means one day.
'endDate' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:date'],
'emotion' => ['numeric', 'min:-1', 'max:1'],
'milestone' => ['boolean'],
'location' => ['nullable', 'string', 'max:255'],
'category' => ['nullable', 'string', 'in:'.implode(',', self::CATEGORIES)],
'people' => ['nullable', 'array', 'max:30'],
// The request middleware turns '' into null; blanks are dropped in toColumns.
'people.*' => ['nullable', 'string', 'max:80'],
'customColor' => ['nullable', 'string', 'max:20'],
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
'image' => ['nullable', 'string', 'max:500'],
'keyImageTitle' => ['nullable', 'string', 'max:255'],
'note' => ['nullable', 'string', 'max:5000'],
];
}
/**
* API fields that must be present when an event is created.
*
* @return array<int, string>
*/
public static function required(): array
{
return ['date', 'emotion'];
}
/**
* API fields that must be sent on create, but may be empty.
*
* @return array<int, string>
*/
public static function present(): array
{
return ['title'];
}
/**
* Rules for creating an event: required fields are mandatory.
*
* @return array<string, array<int, string>>
*/
public static function storeRules(string $prefix = ''): array
{
return self::withPresence($prefix, fn (string $field): array => match (true) {
in_array($field, self::required(), true) => ['required'],
in_array($field, self::present(), true) => ['present'],
default => ['sometimes'],
});
}
/**
* Rules for updating an event: every field is optional, but a field that
* *is* sent must still be valid and required fields may not be nulled.
*
* @return array<string, array<int, string>>
*/
public static function updateRules(string $prefix = ''): array
{
return self::withPresence($prefix, fn (string $field): array => in_array($field, self::required(), true)
? ['sometimes', 'required']
: ['sometimes']);
}
/**
* Map validated API fields onto their database columns.
*
* Only keys actually present in the payload are returned, so a partial
* update never clears a column the client did not mention.
*
* @param array<string, mixed> $validated
* @return array<string, mixed>
*/
public static function toColumns(array $validated): array
{
$columns = [
'title' => 'title',
'date' => 'date',
'endDate' => 'end_date',
'emotion' => 'emotion',
'milestone' => 'milestone',
'location' => 'location',
'category' => 'category',
'people' => 'people',
'customColor' => 'custom_color',
'gradientPreset' => 'gradient_preset',
'image' => 'image',
'keyImageTitle' => 'key_image_title',
'note' => 'note',
];
$data = [];
foreach ($columns as $field => $column) {
if (array_key_exists($field, $validated)) {
$data[$column] = $validated[$field];
}
}
// The column is NOT NULL; the request middleware turns '' into null.
if (array_key_exists('title', $data) && $data['title'] === null) {
$data['title'] = '';
}
if (array_key_exists('people', $data)) {
// Trimmed, without blanks and duplicates; an empty list is null.
$people = array_values(array_unique(array_filter(
array_map(fn ($name) => trim((string) $name), $data['people'] ?? []),
fn (string $name) => $name !== ''
)));
$data['people'] = $people === [] ? null : $people;
}
if (array_key_exists('milestone', $data)) {
$data['milestone'] = (bool) $data['milestone'];
}
return $data;
}
/**
* @param callable(string): array<int, string> $presence
* @return array<string, array<int, string>>
*/
private static function withPresence(string $prefix, callable $presence): array
{
$rules = [];
foreach (self::rules() as $field => $fieldRules) {
$rules[$prefix.$field] = [...$presence($field), ...$fieldRules];
}
return $rules;
}
}

View file

@ -0,0 +1,132 @@
<?php
namespace App\Support;
use App\Rules\ScalarSetting;
/**
* Structural schema for the user settings document.
*
* The settings blob is stored as JSON, so validation is the only thing
* standing between the client and whatever ends up being read back as
* `preset.id` or a shader uniform. Keys are whitelisted, values are bounded
* and required to be scalar, so no nested structure can be smuggled in.
*/
final class SettingsSchema
{
/**
* Current schema version, stored alongside the settings so a later
* migration can tell old documents from new ones.
*/
public const VERSION = 1;
/**
* Maximum number of stored presets per user.
*/
public const MAX_PRESETS = 50;
/**
* Maximum length of any string value inside the settings document.
*/
public const MAX_STRING_LENGTH = 4096;
/**
* Top-level settings keys the API accepts. Anything else is rejected
* rather than quietly persisted.
*
* @return array<int, string>
*/
public static function allowedKeys(): array
{
return [
'version',
'theme',
'floatingLines',
'appearance',
'accentColor',
'language',
'emotionGradientStart',
'emotionGradientEnd',
'timelineZoom',
'timelineScrollLeft',
'showFps',
'presets',
'activePresetId',
];
}
/**
* Keys allowed inside `floatingLines`, mirroring FLOATING_LINES_DEFAULTS
* in the Quasar settings store.
*
* @return array<int, string>
*/
public static function allowedFloatingLineKeys(): array
{
return [
// Lines
'speed', 'lineCount', 'spread', 'fanSpread', 'lineSharpness',
'lineThickness', 'waveFrequency', 'bezierCurvature', 'circleRadius',
'glowSize', 'glowStrength', 'lineBrightness',
// Background
'bgCenter', 'bgEdge', 'gradientStops', 'backgroundImage',
// Horizon
'horizonMode', 'horizonOpacity', 'horizonBlend',
// Labels
'labelSize', 'labelColor', 'labelOpacity', 'labelConnectorLength',
// Dot border
'dotBorderWidth', 'dotBorderColor',
// Mode
'lineMode', 'staticLineColor', 'staticLineShadowStrength',
];
}
/**
* Validation rules for the settings document.
*
* @return array<string, array<int, mixed>>
*/
public static function rules(): array
{
return [
'settings' => ['required', 'array'],
'settings.version' => ['nullable', 'integer', 'min:1', 'max:'.self::VERSION],
...self::snapshotRules('settings.'),
'settings.timelineScrollLeft' => ['nullable', 'numeric', 'min:0'],
'settings.presets' => ['nullable', 'array', 'max:'.self::MAX_PRESETS],
'settings.presets.*' => ['array'],
'settings.presets.*.id' => ['required', 'string', 'max:100'],
'settings.presets.*.name' => ['required', 'string', 'max:100'],
'settings.presets.*.updatedAt' => ['nullable', 'numeric'],
'settings.presets.*.settings' => ['required', 'array'],
...self::snapshotRules('settings.presets.*.settings.'),
'settings.activePresetId' => ['nullable', 'string', 'max:100'],
];
}
/**
* Rules for the fields a preset snapshot shares with the top-level
* settings. Every field needs an explicit rule: Laravel's `validated()`
* only returns keys that have one, so a field left out here would be
* silently dropped when a preset is saved.
*
* @return array<string, array<int, mixed>>
*/
private static function snapshotRules(string $prefix): array
{
return [
$prefix.'theme' => ['nullable', 'string', 'max:50'],
$prefix.'appearance' => ['nullable', 'string', 'max:50'],
$prefix.'accentColor' => ['nullable', 'string', 'max:50'],
$prefix.'language' => ['nullable', 'string', 'max:10'],
$prefix.'emotionGradientStart' => ['nullable', 'string', 'max:20'],
$prefix.'emotionGradientEnd' => ['nullable', 'string', 'max:20'],
$prefix.'timelineZoom' => ['nullable', 'numeric', 'min:0.1', 'max:10'],
$prefix.'showFps' => ['nullable', 'boolean'],
$prefix.'floatingLines' => ['nullable', 'array'],
$prefix.'floatingLines.*' => ['nullable', new ScalarSetting(self::MAX_STRING_LENGTH)],
];
}
}

View file

@ -15,5 +15,11 @@ return Application::configure(basePath: dirname(__DIR__))
//
})
->withExceptions(function (Exceptions $exceptions) {
//
// Every logged exception carries who was acting and where, so a line
// in laravel.log can be matched to a client log entry.
$exceptions->context(fn (): array => array_filter([
'user_id' => auth()->id(),
'path' => request()?->path(),
'method' => request()?->method(),
]));
})->create();

View file

@ -0,0 +1,19 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Layout
|---------------------------------------------------------------------------
|
| Livewire ships with `layouts::app` as its default, which expects a view
| registered under the `layouts` namespace. This application keeps its
| layout at `resources/views/components/layouts/app.blade.php`, so the
| component layout is pointed at that view explicitly.
|
*/
'component_layout' => 'components.layouts.app',
];

View file

@ -73,6 +73,17 @@ return [
'replace_placeholders' => true,
],
// Warnings and errors reported by the Quasar app via POST /api/client-logs.
// Kept apart from the server log so browser-side failures (failed
// uploads, 429s, unhandled exceptions) can be read on their own.
'client' => [
'driver' => 'daily',
'path' => storage_path('logs/client.log'),
'level' => env('LOG_CLIENT_LEVEL', 'debug'),
'days' => env('LOG_CLIENT_DAYS', 30),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),

View file

@ -0,0 +1,350 @@
{
"person": {
"name": "Der Benz (Auto)",
"email": "benz@thats-me.app",
"birthYear": 1981
},
"events": [
{
"age": 0,
"month": 3,
"title": "Abholung im Werk",
"note": "Ein Verkäufer fotografiert Heinz, 45, im Anzug neben seinem funkelnagelneuen hellelfenbeinfarbenen Mercedes 230 E vor dem Mercedes-Werk in Sindelfingen, die Hand auf dem Stern.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 0,
"month": 5,
"title": "Erste Urlaubsfahrt",
"note": "Ein Foto am Brennerpass: der Benz mit Dachgepäckträger, Heinz, seine Frau Gisela und die 16-jährige Sabine davor, Sabine mit Walkman und genervtem Blick.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 0,
"month": 9,
"title": "Erste Beule",
"note": "Heinz fotografiert die erste Delle im hinteren Kotflügel auf dem Supermarktparkplatz, daneben ein zerknirschter Einkaufswagen.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 1,
"month": 6,
"title": "Sabines Fahrstunden",
"note": "Gisela fotografiert Sabine, 17, mit weißen Knöcheln am Lenkrad des Benz auf einem leeren Parkplatz, Heinz auf dem Beifahrersitz mit angespanntem Gesicht.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 2,
"month": 7,
"title": "Adria mit vier Personen",
"note": "Ein Foto von einem Campingplatz an der Adria: der Benz mit offenen Türen, ein Zelt daneben, Sabine und ihre Freundin Petra sonnen sich auf der Motorhaube.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 3,
"month": 4,
"title": "100.000 Kilometer",
"note": "Heinz fotografiert den Kilometerzähler beim Umspringen auf 100.000, seine Hand mit Ehering am Lenkrad.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 4,
"month": 8,
"title": "Sabines Abifeier",
"note": "Ein Foto vor dem Gymnasium: sechs Abiturienten quetschen sich in und auf den Benz, Sabine mit Sektflasche auf dem Dach, Heinz im Hintergrund entsetzt.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 5,
"month": 2,
"title": "Winter ohne Garage",
"note": "Heinz fotografiert den völlig eingeschneiten Benz vor dem Haus, nur der Stern schaut aus dem Schnee.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 7,
"month": 5,
"title": "Hochzeitsauto",
"note": "Der Hochzeitsfotograf hält den Benz mit weißen Bändern und Blumen auf der Haube fest, Sabine im Brautkleid und ihr Mann Jörg steigen vor der Kirche aus, Heinz hält die Tür.",
"area": "Hochzeit",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 9,
"month": 10,
"title": "Erster Rost",
"note": "Heinz fotografiert in der Werkstatt den Wagenheber und die aufgebockte Karosserie, ein Mechaniker zeigt mit dem Finger auf braune Stellen am Radlauf.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 10,
"month": 3,
"title": "Zehn Jahre",
"note": "Gisela fotografiert Heinz, der den Benz zum zehnten Geburtstag mit einem Eimer und Schwamm poliert, ein selbstgemaltes Schild lehnt an der Stoßstange.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 12,
"month": 6,
"title": "Max' erste Fahrt",
"note": "Sabine fotografiert Heinz, der den wenige Tage alten Enkel Max in einer Babyschale auf die Rückbank des Benz setzt, überfordert und stolz.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 14,
"month": 7,
"title": "Norwegen mit Anhänger",
"note": "Ein Foto an einem norwegischen Fjord: der Benz mit einem kleinen Wohnwagen, Heinz und Gisela mit Regenjacken, der Wagen ist komplett verdreckt.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 16,
"month": 11,
"title": "Gisela stirbt",
"note": "Sabine fotografiert den Benz auf dem Friedhofsparkplatz im Regen, Heinz sitzt allein am Steuer und schaut geradeaus.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ"
},
{
"age": 17,
"month": 4,
"title": "Heinz gibt den Schlüssel ab",
"note": "Jörg fotografiert Heinz, 61, wie er Sabine in der Einfahrt den Schlüssel des Benz in die Hand legt, beide sehen nicht in die Kamera.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "Heinz, 61, and his daughter Sabine standing in the driveway of a suburban house beside the beige Mercedes W123 saloon, Heinz placing the car key into Sabine's open hand, both looking at the key and not at the camera. The car is completely intact and correctly built: all four doors shut and flush with the body, the exterior mirror on the outside of the door, no panel removed or reversed."
},
{
"age": 17,
"month": 9,
"title": "Familienkutsche",
"note": "Sabine fotografiert den vollgepackten Benz vor dem Kindergarten: Kindersitz, Matschhose, ein halber Apfel im Fußraum.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "The beige Mercedes W123 saloon parked at the kerb in front of a kindergarten building on an autumn morning, seen from the side. The near-side rear door stands wide open towards the camera and is complete in every part: door panel, window frame, glass, handle and hinges at the front edge, showing a child seat on the back seat, muddy rain trousers and a half-eaten apple in the footwell. The other three doors are closed and flush with the body; the car has exactly four doors, no opening or missing panel anywhere else."
},
{
"age": 19,
"month": 8,
"title": "Gardasee",
"note": "Jörg fotografiert Sabine und den 7-jährigen Max, die auf der Motorhaube des Benz mit Blick auf den Gardasee ein Eis essen.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 20,
"month": 5,
"title": "Max lernt Gänge",
"note": "Sabine fotografiert Max, 8, auf Heinz' Schoß am Steuer des Benz auf einem Feldweg, beide grinsen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 22,
"month": 2,
"title": "TÜV verweigert",
"note": "Jörg fotografiert den TÜV-Prüfbericht (unleserlich) auf der Motorhaube und Sabine, die sich die Hand vor das Gesicht hält.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ"
},
{
"age": 22,
"month": 3,
"title": "Unter der Plane",
"note": "Sabine fotografiert den Benz unter einer grauen Plane in der Scheune ihrer Nachbarn, nur die Räder schauen hervor.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 25,
"month": 6,
"title": "Max und die Plane",
"note": "Sabine fotografiert Max, 13, der in der Scheune die Plane vom Benz zieht und mit offenem Mund davorsteht.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 26,
"month": 4,
"title": "Heinz stirbt",
"note": "Max fotografiert den staubigen Benz in der Scheune, auf dem Fahrersitz liegt Heinz' alte Schiebermütze.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ",
"imageCharacters": [
"benz"
],
"imagePrompt": "The dusty beige Mercedes W123 standing alone in a dim barn between old farm tools, seen through the open driver's window: an old grey flat cap lies on the driver's seat, dust on the dashboard, a shaft of light from a small window. Nobody is in the picture, no person anywhere."
},
{
"age": 28,
"month": 9,
"title": "Max' erste Schrauberei",
"note": "Sabine fotografiert Max, 16, mit ölverschmierten Händen und einem Reparaturhandbuch unter dem offenen Motorraum des Benz.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 29,
"month": 7,
"title": "Motor läuft wieder",
"note": "Ein Freund filmt: Max, 17, springt vom Fahrersitz, als der Motor nach sieben Jahren zum ersten Mal wieder anspringt, die Scheune voller blauem Rauch.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 30,
"month": 3,
"title": "Bewerbungsfahrt",
"note": "Sabine fotografiert Max, 18, im Anzug neben dem Benz vor der Ausbildungswerkstatt, das Auto halb restauriert mit grundierten Kotflügeln.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 31,
"month": 10,
"title": "Schweißarbeiten",
"note": "Ein Kollege fotografiert Max mit Schweißmaske unter dem aufgebockten Benz, Funkenflug.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 33,
"month": 5,
"title": "Neuer Lack",
"note": "Der Lackierer fotografiert Max vor dem frisch lackierten hellelfenbeinfarbenen Benz in der Lackierkabine, Max streicht mit der Hand über die Haube.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 33,
"month": 8,
"title": "Sabine fährt wieder",
"note": "Max fotografiert seine Mutter Sabine, 49, die zum ersten Mal seit elf Jahren wieder am Steuer des Benz sitzt und weint.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 34,
"month": 6,
"title": "H-Kennzeichen",
"note": "Ein Foto vor der Zulassungsstelle: Max schraubt das neue H-Kennzeichen an den Benz, Sabine hält das alte Kennzeichen hoch.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "In front of a vehicle registration office: Max, 25, kneeling at the front bumper of the beige Mercedes W123 and screwing on the new number plate with a screwdriver, Sabine standing beside him holding the old plate up in her hand. Both number plates are flat rectangular plates seen almost straight on, plain white with a plain black border and no readable characters, completely undistorted and not warped or bent."
},
{
"age": 35,
"month": 7,
"title": "Oldtimertreffen",
"note": "Ein Besucher fotografiert Max und den Benz auf einem Oldtimertreffen zwischen anderen W123, ein Schild mit dem Baujahr auf der Haube (unleserlich).",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 36,
"month": 9,
"title": "Max' Hochzeitsauto",
"note": "Der Hochzeitsfotograf hält den Benz mit weißen Bändern fest, diesmal steigen Max und seine Frau Lea vor demselben Kirchenportal aus, Sabine hält die Tür.",
"area": "Hochzeit",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 38,
"month": 4,
"title": "Panne auf der A3",
"note": "Lea fotografiert Max, der auf dem Standstreifen mit Warnweste neben dem Benz mit offener Haube steht, Abschleppwagen im Anflug.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 39,
"month": 6,
"title": "Max' Tochter",
"note": "Lea fotografiert Max, der seine neugeborene Tochter Emma in einer Babyschale auf die Rückbank des Benz setzt – dieselbe Rückbank wie 1993.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Max, 30, putting a newborn baby in a baby car seat onto the back seat of the beige Mercedes W123, seen from the pavement. Exactly one single door of the car is open: the rear door on the pavement side, hinged at its front edge and swung wide open. All three other doors, including the front door next to it, are firmly shut and flush with the body. Max stands in that one open doorway, in the gap between the open door and the car body, bending forward into the car with both arms above the back seat; he does not stand behind the door and no arm reaches over or through it."
},
{
"age": 41,
"month": 7,
"title": "Brenner, zweite Runde",
"note": "Ein Foto am Brennerpass: der Benz mit Dachgepäckträger, Max, Lea und die zweijährige Emma davor – nachgestellt wie das Foto von 1981, Sabine hält das alte Bild daneben.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 43,
"month": 10,
"title": "Sabine wird 60",
"note": "Max fotografiert Sabine, die zu ihrem 60. Geburtstag vor dem Benz eine Fahrt mit Emma auf dem Beifahrersitz macht, beide winken.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 44,
"month": 3,
"title": "300.000 Kilometer",
"note": "Max fotografiert den Kilometerzähler beim Umspringen auf 300.000, seine Hand mit Ehering am Lenkrad, auf dem Beifahrersitz Emma, 5.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 44,
"month": 12,
"title": "Drei Generationen",
"note": "Ein Nachbar fotografiert Sabine, Max und Emma vor dem Benz in der Einfahrt, Emma sitzt auf der Haube, dahinter das Haus, in dem Heinz 1981 den Wagen zum ersten Mal parkte.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
}
]
}

View file

@ -0,0 +1,513 @@
{
"person": {
"name": "Bruno",
"email": "bruno@thats-me.app",
"birthYear": 2014
},
"events": [
{
"age": 0,
"month": 3,
"title": "Geboren",
"note": "Die Züchterin fotografiert den Wurf von sieben Welpen, Bruno ist der mit dem weißen Fleck auf der Nase, der als Einziger schläft.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "In a breeder's living room on a soft blanket: a litter of seven newborn yellow Labrador puppies sleeping in a pile next to their mother; one puppy in the middle with a small white patch of fur on the bridge of his nose is the only one fast asleep on his back, tiny, eyes still closed; warm indoor light",
"imageAspect": "4:3",
"imageCharacters": []
},
{
"age": 0,
"month": 5,
"title": "Einzug bei Lena",
"note": "Lena fotografiert Bruno in seiner ersten Nacht im neuen Zuhause: im Körbchen, aber mit dem Kopf auf Lenas Hausschuh.",
"area": "Wohnen",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "First night in a new home: Bruno as a tiny eight-week-old puppy curled up in a wicker dog basket in a hallway, his head resting on a worn grey felt house slipper he dragged in, the puppy tiny compared to the basket; dim evening lamp light, tender",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 0,
"month": 6,
"title": "Erste Impfung",
"note": "Die Tierarzthelferin fotografiert Bruno auf dem Metalltisch, alle vier Pfoten gespreizt, Blick auf die Spritze.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "At the vet: Bruno as a three-month-old puppy standing on a shiny stainless steel examination table, all four legs spread wide and stiff, staring anxiously at a syringe in a vet assistant's hand at the edge of the frame; bright clinical light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 0,
"month": 7,
"title": "Sofakissen zerlegt",
"note": "Lena fotografiert Bruno inmitten einer Wolke aus Füllwatte, ein Kissenzipfel hängt ihm noch aus dem Maul.",
"area": "Wohnen",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "In a living room: Bruno as a four-month-old puppy sitting alone proudly in the middle of an exploded sofa cushion, white polyester stuffing everywhere like a cloud, a corner of the cushion cover hanging from his mouth; only one dog; daylight from a window",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 0,
"month": 8,
"title": "Welpenschule",
"note": "Die Trainerin fotografiert Bruno, der statt „Sitz“ zu machen die Dackeldame Frieda anhimmelt.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "At an outdoor puppy training class on a lawn: Bruno as a five-month-old puppy ignoring the 'sit' command and gazing adoringly at a small brown dachshund puppy sitting next to him; other puppies and owners' legs blurred in the background; summer evening light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 0,
"month": 11,
"title": "Erste Ostsee",
"note": "Lena fotografiert Bruno am Strand von Warnemünde, der vor jeder Welle einen Satz rückwärts macht.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "On a wide sandy Baltic Sea beach in November: Bruno as an eight-month-old puppy mid-jump backwards away from a small incoming wave, ears flying, startled expression, grey sea, empty beach",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 1,
"month": 1,
"title": "Erster Schnee",
"note": "Ein Foto im Park zeigt Bruno mit der Schnauze komplett im Schnee vergraben, nur die Rute ragt heraus.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "In a snowy city park: Bruno has buried his whole snout deep in fresh powder snow, only his ears and his wagging tail stick up, snow on his back, joyful; bright overcast daylight, bare trees, wide shot",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 1,
"month": 4,
"title": "Kastration",
"note": "Lena fotografiert Bruno mit Halskrause, der beim Versuch, durch die Tür zu gehen, am Rahmen hängen bleibt.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ",
"imagePrompt": "Indoors in an apartment hallway: Bruno wearing a large transparent plastic veterinary cone collar after surgery, stuck sideways in a door frame because the cone is too wide, looking puzzled and slightly offended at the camera; full body visible",
"imageAspect": "3:4",
"imageCharacters": [
"bruno"
]
},
{
"age": 1,
"month": 6,
"title": "Erste Wanderung",
"note": "Ein Wanderer fotografiert Lena und Bruno auf dem Brocken, Bruno trägt seinen eigenen kleinen Rucksack mit Leckerlis.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "On the rocky summit of the Brocken mountain in the Harz on a sunny June day: Bruno standing proudly wearing a small red dog backpack, tongue out, Lena crouching next to him in hiking clothes with trekking poles, wide view over forested hills",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"lena"
]
},
{
"age": 1,
"month": 9,
"title": "Ein Kilo Butter",
"note": "Lena fotografiert die leere Butterschale auf dem Küchenboden und Bruno daneben, der so tut, als hätte er das nie gesehen.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "On a kitchen floor: an empty white ceramic butter dish licked clean lying on the tiles, and next to it Bruno sitting and looking pointedly away from the camera at the wall, pretending innocence, a bit of butter on his whiskers; morning light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 1,
"month": 12,
"title": "Weihnachtsbaum umgeworfen",
"note": "Ein Foto vom liegenden Weihnachtsbaum, darunter Bruno mit einer Lichterkette um den Hals.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "In a living room on Christmas Eve: a fully decorated Christmas tree toppled over onto the carpet, baubles scattered, and underneath its branches Bruno peeking out with a string of warm fairy lights tangled around his neck, guilty but cute; cosy evening light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 2,
"month": 3,
"title": "Begleithundprüfung bestanden",
"note": "Die Prüferin fotografiert Bruno mit Urkunde im Maul, Lena hält die Leine und sieht erleichterter aus als er.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "On a dog training field in spring: Bruno sitting proudly holding a rolled-up blank white paper tied with a red ribbon in his mouth (no writing), Lena kneeling next to him holding the leash and looking more relieved than he does; overcast daylight",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"lena"
]
},
{
"age": 2,
"month": 8,
"title": "Erstes Eis in Italien",
"note": "Ein Kellner am Gardasee fotografiert Bruno mit einer Kugel Hundeeis, die er in einer Sekunde verschlingt.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "At a lakeside café terrace on Lake Garda in August: Bruno sitting at a café table, snout right in front of a small plain paper cup of white dog ice cream held by a hand, eyes wide with anticipation, blue lake and mountains blurred behind; bright summer sun",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 3,
"month": 5,
"title": "Jan zieht ein",
"note": "Lena fotografiert Bruno, der demonstrativ auf Jans Seite des Betts liegt und ihn nicht anschaut.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "In a bedroom in the morning: Bruno lying stretched out demonstratively across one entire side of a double bed on top of the duvet, head turned away from Jan who stands at the edge of the frame next to moving boxes, looking amused; soft window light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"jan"
]
},
{
"age": 3,
"month": 10,
"title": "Im Fuchskot gewälzt",
"note": "Jan fotografiert Bruno in der Badewanne, Lena mit Wäscheklammer auf der Nase im Hintergrund.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "In a small bathroom: Bruno sitting miserably in a white bathtub full of foam, wet and sad, while Lena behind him holds a shower head with a wooden clothes peg on her nose, laughing; warm bathroom light",
"imageAspect": "3:4",
"imageCharacters": [
"bruno",
"lena"
]
},
{
"age": 4,
"month": 2,
"title": "Frieda wiedergetroffen",
"note": "Friedas Frauchen fotografiert Bruno und Frieda, die sich nach drei Jahren am Hundeplatz wiedererkennen und minutenlang umeinander springen.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "On a fenced dog park in February: Bruno and a small brown dachshund bouncing around each other mid-air in pure joy, both with mouths open in a play grin, muddy grass, grey winter light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 4,
"month": 7,
"title": "Planschbecken",
"note": "Jan fotografiert Bruno bei 36 Grad im Kinderplanschbecken auf dem Balkon, nur die Nase über Wasser.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "On a small sunny city balcony in July: Bruno lying fully submerged in a small blue inflatable children's paddling pool, only his nose, eyes and ears above the water, blissful expression, harsh midday summer light, potted plants around, balcony railing visible",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 4,
"month": 11,
"title": "Erste graue Haare",
"note": "Ein Nahfoto von Brunos Schnauze mit den ersten weißen Haaren am Kinn, Lena hat es „Silberfuchs“ genannt.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "Extreme close-up of Bruno's muzzle: the first few white hairs on his chin clearly visible, brown eyes looking softly at the camera, shallow depth of field, warm autumn window light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 5,
"month": 4,
"title": "Emma kommt",
"note": "Jan fotografiert Bruno, der neben dem Kinderwagen liegt und seit drei Stunden nicht aufgestanden ist.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "In a living room in April: a grey baby stroller with newborn Emma asleep inside, and Bruno lying pressed against the stroller wheel on the floor, chin on his paws, keeping watch, calm and protective; soft daylight",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"emma"
]
},
{
"age": 5,
"month": 9,
"title": "Zecke",
"note": "Lena fotografiert Bruno beim Tierarzt mit geschorener Stelle am Bein nach dem Borreliose-Schreck – Entwarnung.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "At a veterinary clinic: Bruno sitting on the floor with a small shaved patch on his front leg with a tiny bandage, tilting his head at the camera, Lena's hand ruffling his ears reassuringly; clinical light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"lena"
]
},
{
"age": 6,
"month": 3,
"title": "Alle den ganzen Tag zu Hause",
"note": "Ein Foto vom Homeoffice-Schreibtisch: Jan am Laptop, Lena am Telefon, Bruno auf beiden Füßen – das beste Jahr seines Lebens.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Home office during lockdown in March 2020: a wide shot of a living room turned office, Jan typing on a laptop at a desk, Lena on the phone at the dining table, and Bruno lying sprawled across both of their feet under the table, utterly content; daylight",
"imageAspect": "16:9",
"imageCharacters": [
"bruno",
"jan",
"lena"
]
},
{
"age": 6,
"month": 10,
"title": "„Buno“",
"note": "Ein Video-Standbild von Emma, die zum ersten Mal „Buno“ sagt und ihm die Hand auf die Nase legt.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "In a living room: toddler Emma places her small hand on Bruno's nose, Bruno sitting very still and patient, both face to face at eye level; warm afternoon light, slightly blurry like a video still",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"emma"
]
},
{
"age": 7,
"month": 6,
"title": "Kreuzbandriss",
"note": "Lena fotografiert Bruno nach der OP in der Tierklinik, rasiertes Bein, Kopf auf ihrem Schoß.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ",
"imagePrompt": "In a veterinary clinic recovery room: Bruno lying on a blanket after knee surgery, one hind leg shaved and stitched, his head resting heavily in Lena's lap as she sits on the floor, his eyes half closed; cool clinical light, emotional",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"lena"
]
},
{
"age": 7,
"month": 8,
"title": "Unterwasserlaufband",
"note": "Die Physiotherapeutin fotografiert Bruno auf dem Unterwasserlaufband, Blick wie ein alter Mann in der Aquagymnastik.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "In a dog physiotherapy practice: Bruno walking slowly on an underwater treadmill inside a glass tank, water up to his chest, with the resigned dignified expression of an old man at aqua aerobics; bright practice light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 7,
"month": 12,
"title": "Wieder auf dem Sofa",
"note": "Jan fotografiert Bruno, der zum ersten Mal seit der OP wieder allein aufs Sofa springt und sehr zufrieden mit sich ist.",
"area": "Wohnen",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "In a living room at Christmas: Bruno has just jumped onto a grey sofa for the first time after surgery and sits there extremely pleased with himself, chest out, Christmas tree lights in the background; cosy evening light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 8,
"month": 5,
"title": "Haus mit Garten",
"note": "Lena fotografiert Bruno am Umzugstag mitten auf dem Rasen des neuen Gartens, auf dem Rücken, alle Viere in der Luft.",
"area": "Wohnen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Moving day in May: Bruno lying on his back in the middle of the green lawn of a small suburban garden, all four legs in the air, tongue lolling, pure bliss, moving boxes and a house with an open patio door in the background; bright spring sun",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 8,
"month": 9,
"title": "Igel",
"note": "Jan fotografiert Bruno mit geschwollener Nase neben einem völlig unbeeindruckten Igel unter der Hecke.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "In a garden under a hedge in September: Bruno with a visibly swollen snout looking wounded at the camera, and next to him a small hedgehog completely unimpressed, curled up on the lawn; evening light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 9,
"month": 4,
"title": "Tumor an der Milz",
"note": "Lena fotografiert Bruno im Wartezimmer vor dem Ultraschall; der Tumor ist gutartig, das weiß aber noch keiner.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "In a veterinary waiting room: Bruno lying on the floor with his head on Lena's shoes as she sits on a chair, waiting, calm but tired; posters on the wall blurred, neutral clinical light, quiet mood",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"lena"
]
},
{
"age": 9,
"month": 7,
"title": "Letzte Ostsee",
"note": "Emma fotografiert Bruno am Strand, der die Wellen inzwischen ignoriert und lieber in der Sonne liegt.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "On a Baltic Sea beach in July: Bruno lying comfortably in warm sand ignoring the small waves rolling in behind him, four-year-old Emma with a bucket digging next to him; soft summer evening light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"emma"
]
},
{
"age": 9,
"month": 11,
"title": "Rampe für die Treppe",
"note": "Jan fotografiert Bruno, der die neue Holzrampe zur Terrasse misstrauisch beschnuppert, Emma zeigt ihm, wie es geht.",
"area": "Wohnen",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "On a garden terrace in November: a new wooden ramp leading up to the patio door, Bruno sniffing it suspiciously with one paw on it, four-year-old Emma standing at the top of the ramp waving him up; only one dog; overcast autumn light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"emma"
]
},
{
"age": 10,
"month": 2,
"title": "10. Geburtstag",
"note": "Lena fotografiert Bruno mit Partyhut vor einer Leberwursttorte mit zehn Kerzen, Emma hat sie selbst dekoriert.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Tenth birthday in a kitchen: Bruno wearing a small paper party hat sitting in front of a dog cake made of liver sausage decorated with ten candles and dog treats, five-year-old Emma next to him proudly presenting the cake she decorated; warm indoor light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"emma"
]
},
{
"age": 10,
"month": 5,
"title": "Nierenwerte",
"note": "Lena fotografiert Bruno beim Tierarzt, der jetzt Spezialfutter bekommt und es zunächst verweigert.",
"area": "Alltag",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "In a kitchen: Bruno sitting in front of a full bowl of special diet kibble, looking at it with deep distrust and then up at the camera, refusing to eat; morning light",
"imageAspect": "4:3",
"imageCharacters": [
"bruno"
]
},
{
"age": 10,
"month": 8,
"title": "Letzter Sommer im Garten",
"note": "Jan fotografiert Emma, die Bruno im Garten aus ihrem Erstlesebuch vorliest; Bruno hört zu, mehr oder weniger.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Last summer in the garden in August: Bruno lying on a blanket in the shade of an apple tree, five-year-old Emma sitting next to him reading aloud from a children's picture book (pages blank), holding it up for him to see; golden evening light, tender",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"emma"
]
},
{
"age": 10,
"month": 10,
"title": "Abschied",
"note": "Ein letztes Foto zeigt Bruno auf seiner Decke im Garten, Lena, Jan und Emma um ihn herum, die Tierärztin ist nach Hause gekommen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ",
"imagePrompt": "In a garden in October: Bruno lying peacefully on his favourite blanket on the lawn, Lena, Jan and Emma kneeling around him stroking him, seen from slightly above, autumn leaves on the grass; soft overcast light, very quiet and emotional",
"imageAspect": "4:3",
"imageCharacters": [
"bruno",
"lena",
"jan",
"emma"
]
},
{
"age": 10,
"month": 11,
"title": "Pfotenabdruck",
"note": "Lena fotografiert die Erinnerungsecke im Flur: Brunos Pfotenabdruck in Ton, sein Halsband und das Foto mit dem Schnee auf der Nase.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "A small memorial corner on a hallway shelf: a round clay disc with a dog paw print pressed into it, a worn brown leather dog collar with a tag, and a framed photo of a yellow Labrador with a white patch on the nose and snow on his snout; a small candle, soft warm light, still life",
"imageAspect": "3:4",
"imageCharacters": []
}
]
}

View file

@ -0,0 +1,195 @@
{
"lifeline": "benz",
"style": "Candid amateur photo, realistic family snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
"era": {
"1981-1995": "shot on 35mm colour negative film with a compact camera: film grain, warm faded colours, slight vignetting, 1980s/early-90s clothing, cars and interiors",
"1996-2004": "shot on 35mm film or an early digital camera: slightly soft, muted colours, late-90s/early-2000s clothing and interiors",
"2005-2009": "shot on an early digital compact camera or camera phone: slightly soft, mild noise, warm tint, 2000s clothing and interiors",
"2010-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, 2010s clothing and interiors",
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
},
"familyNote": "The car is the same individual vehicle in every photo: a light ivory (hellelfenbein) Mercedes-Benz W123 230 E saloon, 1981, chrome bumpers, round headlights with the classic rectangular grille, hubcaps, black interior.",
"characters": {
"benz": {
"name": "the Mercedes",
"birthYear": 1981,
"birthMonth": 3,
"aliases": [
"Benz",
"Mercedes",
"Wagen",
"Auto"
],
"description": "a light ivory (hellelfenbein) Mercedes-Benz W123 230 E four-door saloon, model year 1981, chrome bumpers and trim, round headlights and the classic upright grille with the star, steel wheels with silver hubcaps, black MB-Tex interior",
"ageLabels": {
"0": "brand new",
"1": "one year old"
},
"aging": {
"8": "first stone chips, slightly dulled paint, small dent in the rear wing",
"17": "tired paint, rust bubbles at the wheel arches, a cracked dashboard, worn seats",
"22": "under a grey tarpaulin in a barn, flat tyres, dust, rust at the sills",
"30": "half restored: primed grey wings, new sills, engine bay cleaned, still no final paint",
"34": "fully restored, gleaming ivory paint, new chrome, period-correct H-plate, like new but with a warm patina inside"
},
"base": 0,
"anchors": [
0,
8,
17,
22,
30,
34
],
"portrait": "Three-quarter front view of the whole car, parked, in flat daylight, plain background, no people, no text."
},
"heinz": {
"name": "Heinz",
"birthYear": 1936,
"aliases": [
"Heinz",
"Opa"
],
"description": "German man, first owner of the car. Broad face, short grey hair combed to the side, thick dark eyebrows, a grey moustache, glasses with thick frames, a proud upright posture. Style: suits in the 80s, later cardigans and a flat cap.",
"aging": {
"55": "hair white, moustache white, a bit heavier",
"61": "white hair, stooped, tired eyes after his wife's death"
},
"anchors": [
45,
55,
61
]
},
"gisela": {
"name": "Gisela",
"birthYear": 1938,
"diedYear": 1997,
"aliases": [
"Gisela",
"Oma"
],
"description": "Heinz's wife. Round friendly face, permed light brown hair, blue eyes, rosy cheeks, always a headscarf in the car. Style: floral dresses, cardigans.",
"aging": {
"55": "hair grey, permed, glasses"
},
"anchors": [
43,
55
]
},
"sabine": {
"name": "Sabine",
"birthYear": 1965,
"aliases": [
"Sabine"
],
"description": "Heinz and Gisela's daughter. Long face, long dark blonde hair (permed in the 80s, later a bob), grey eyes, her father's eyebrows, a wry smile. Style: 80s denim and Walkman as a teen, later blouses and jeans.",
"aging": {
"23": "big permed hair, 80s wedding style",
"33": "shoulder-length bob, a young mother in practical clothes",
"49": "chin-length bob with grey strands, fine lines",
"60": "short grey hair, glasses, laugh lines"
},
"anchors": [
16,
23,
33,
49,
60
]
},
"joerg": {
"name": "Jörg",
"birthYear": 1962,
"aliases": [
"Jörg"
],
"description": "Sabine's husband. Tall, short brown hair, a full beard, brown eyes, a calm smile. Style: checked shirts, jeans.",
"aging": {
"45": "beard greying, hair thinner"
},
"anchors": [
26,
45
]
},
"max": {
"name": "Max",
"birthYear": 1993,
"birthMonth": 6,
"aliases": [
"Max",
"Enkel"
],
"parents": [
"sabine",
"joerg"
],
"description": "Sabine's son, Heinz's grandson. Round face as a child, later a long face with his grandfather's eyebrows, short dark blonde hair, grey eyes, oil under the fingernails from his teens on. Style: hoodies, work overalls, later a suit for special days.",
"ageLabels": {
"0": "a newborn baby"
},
"aging": {
"0": "newborn, a few days old, wrinkled skin, fine blonde fuzz",
"7": "seven-year-old boy, round face, short blonde hair, gap teeth",
"13": "thirteen-year-old boy, lanky, dark blonde hair, first acne",
"17": "seventeen-year-old, tall, short dark blonde hair, oil-stained hands, focused",
"25": "young man, short dark blonde hair, a short beard, his grandfather's eyebrows, calm confident look",
"32": "early thirties, short beard, first fine lines"
},
"base": 17,
"baseDescription": "seventeen-year-old young man, long face, thick dark eyebrows, short dark blonde hair, grey eyes, oil-stained hands, focused expression",
"anchors": [
0,
7,
13,
17,
25,
32
]
},
"lea": {
"name": "Lea",
"birthYear": 1994,
"aliases": [
"Lea"
],
"description": "Max's wife. Oval face, long curly red-brown hair, green eyes, freckles, a wide laugh. Style: dresses, cardigans.",
"aging": {},
"anchors": [
27
]
},
"emma": {
"name": "Emma",
"birthYear": 2020,
"birthMonth": 6,
"aliases": [
"Emma"
],
"parents": [
"max",
"lea"
],
"description": "Max and Lea's daughter. Round face, curly red-brown hair like her mother, grey eyes, freckles.",
"ageLabels": {
"0": "a newborn baby"
},
"aging": {
"0": "newborn, a few days old",
"2": "two-year-old toddler, curly red-brown hair, chubby cheeks",
"5": "five-year-old girl, curly red-brown hair in a ponytail, freckles"
},
"base": 2,
"baseDescription": "two-year-old toddler girl, round face, curly red-brown hair, grey eyes, chubby cheeks",
"anchors": [
0,
2,
5
]
}
},
"partners": [],
"family": []
}

View file

@ -0,0 +1,88 @@
{
"lifeline": "bruno",
"style": "Candid amateur smartphone photo, realistic family snapshot, natural light, no retouching, no text or readable writing anywhere, no watermark.",
"portrait": "Close-up portrait of the head and chest, looking straight into the camera, neutral bright daylight, plain light wall background, no text.",
"era": {
"2014-2016": "shot on an early smartphone: decent sharpness, slightly flat colours",
"2017-2024": "shot on a modern smartphone: sharp, clean colours"
},
"characters": {
"bruno": {
"name": "Bruno",
"birthYear": 2014,
"description": "a yellow Labrador retriever mix dog: sandy golden short coat, slightly floppy ears, a distinctive small white patch of fur on the bridge of the nose just above the black nose leather, warm brown eyes, black nose, a lighter cream-coloured chest",
"ageLabels": {
"0": "a puppy about three months old",
"1": "a one-year-old young dog"
},
"aging": {
"0": "a small puppy about three months old: small body, oversized paws, soft baby fur, short puppy muzzle, floppy ears, the white patch on the nose already visible",
"1": "a lanky one-year-old young dog, adult size but slim, smooth face",
"6": "adult dog in his prime, first few white hairs on the chin",
"8": "mature dog, muzzle and chin visibly greying, slightly heavier",
"10": "senior dog, muzzle and eyebrows white-grey, slightly cloudy eyes, thinner, gentle tired expression"
},
"base": 3,
"anchors": [
0,
1,
3,
6,
8,
10
],
"birthMonth": 3
},
"lena": {
"name": "Lena",
"birthYear": 1985,
"description": "German woman, Bruno's owner. Oval face, long dark brown hair usually in a messy bun, dark brown eyes, a small nose, a wide smile with a dimple on the right cheek, tortoiseshell glasses sometimes. Style: hoodies, jeans, rain jackets, sneakers.",
"aging": {
"38": "a few fine lines around the eyes, hair a bit shorter"
},
"anchors": [
29,
38
]
},
"jan": {
"name": "Jan",
"birthYear": 1983,
"description": "German man, Lena's partner. Long narrow face, short dark blonde hair, blue eyes, a short beard, thin-rimmed glasses, a calm friendly smile. Tall. Style: t-shirts, flannel shirts, a beanie.",
"aging": {
"40": "beard with a bit of grey, slightly receding hairline"
},
"anchors": [
34,
40
]
},
"emma": {
"name": "Emma",
"birthYear": 2019,
"description": "Daughter of Lena and Jan. Round face, fine blonde hair, her mother's dark brown eyes and dimple, a small button nose, a cheeky grin.",
"ageLabels": {
"0": "a newborn baby",
"1": "a toddler about 18 months old"
},
"aging": {
"0": "newborn, a few days old, wrinkled pink skin, fine blonde fuzz, eyes closed",
"1": "toddler about 18 months old, chubby cheeks, wispy blonde hair, big dark brown eyes",
"5": "five-year-old girl, blonde hair in a ponytail, dimple, missing a front tooth"
},
"base": 3,
"baseDescription": "three-year-old girl, round face, fine blonde hair in two small bunches, dark brown eyes, a dimple on the right cheek, button nose, cheeky grin",
"parents": [
"lena",
"jan"
],
"anchors": [
0,
1,
3,
5
],
"birthMonth": 4
}
}
}

View file

@ -0,0 +1,191 @@
{
"lifeline": "jonas",
"style": "Candid amateur photo, realistic family snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers or screens shown blank or out of focus, no watermark.",
"era": {
"2005-2009": "shot on an early digital compact camera or first-generation camera phone: slightly soft, mild noise, warm tint, small flash reflections, early-2000s clothing and interiors",
"2010-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, 2010s clothing and interiors",
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
},
"characters": {
"jonas": {
"name": "Jonas",
"birthYear": 1980,
"description": "German man, civil engineer from Cologne. Oval face, medium-short dark brown hair with a slight side part, brown eyes, straight nose, a small mole on his left cheekbone just below the eye, usually light stubble, friendly slightly shy smile with a small gap between the front teeth. Casual style: plain t-shirts, hoodies, checked shirts, later simple shirts and knit sweaters.",
"aging": {
"30": "hair a touch shorter, face slightly more defined, still no grey",
"35": "first fine lines around the eyes, a few grey hairs at the temples, short well-kept beard instead of stubble",
"40": "noticeably more grey at the temples and in the beard, deeper laugh lines, slightly fuller face",
"45": "salt-and-pepper hair and beard, receding a little at the temples, lines on the forehead, calm settled expression"
},
"anchors": [
25,
30,
35,
40,
45
],
"aliases": [
"Jonas"
]
},
"mara": {
"name": "Mara",
"birthYear": 1980,
"description": "German woman, physiotherapist. Heart-shaped face, shoulder-length wavy chestnut brown hair usually tied back loosely, green-grey eyes, light freckles across the nose and cheeks, small chin, warm open laugh showing her teeth. Slim, athletic. Casual style: jeans, simple tops, cardigans, a thin silver necklace she always wears.",
"aging": {
"35": "hair a bit shorter, a few fine lines around the eyes, freckles still visible",
"45": "hair cut to a chin-length bob with some grey strands, lines around the eyes and mouth, same freckles, same necklace",
"30": "hair a little longer, otherwise unchanged",
"40": "shoulder-length hair with the first grey strands, fine lines around the eyes and mouth, same freckles, same necklace"
},
"anchors": [
25,
30,
35,
40,
45
],
"aliases": [
"Mara"
]
},
"emil": {
"name": "Emil",
"birthYear": 2012,
"description": "Son of Jonas and Mara. Round face as a small child that lengthens as he grows, light brown hair that darkens over the years, his mother's green-grey eyes and freckles, his father's small gap between the front teeth, sticking-out ears, a curious, serious look.",
"aging": {
"0": "newborn, a few days old, wrinkled red-pink skin, fine light fuzz of hair, eyes mostly closed",
"2": "toddler, chubby cheeks, light brown wispy hair, big green-grey eyes, first freckles barely visible",
"9": "school boy, slim, light brown hair with a fringe, freckles clearly visible, gap between the front teeth, ears sticking out",
"13": "young teenager, taller and lanky, hair darker and longer falling over the forehead, freckles, braces on his teeth, ears less prominent, slightly guarded expression",
"1": "one-year-old baby, chubby cheeks, fine light hair, big green-grey eyes",
"6": "six-year-old boy, round face, light brown hair, freckles, gap between the front teeth, ears sticking out",
"11": "eleven-year-old boy, slimmer face, light brown hair, freckles, gap between the front teeth, ears sticking out"
},
"base": 4,
"baseDescription": "four-year-old boy, round face, light brown hair, green-grey eyes, a few freckles on the nose, small gap between the front teeth, sticking-out ears, curious serious look",
"anchors": [
0,
1,
2,
4,
6,
9,
11,
13
],
"parents": [
"jonas",
"mara"
],
"aliases": [
"Emil",
"Sohn"
],
"ageLabels": {
"0": "a newborn baby",
"1": "a one-year-old baby"
},
"birthMonth": 8
},
"david": {
"name": "David",
"birthYear": 1980,
"description": "Jonas' best friend since school. Broad face, short curly black hair, dark brown eyes, thick eyebrows, wide grin, a bit stocky, often in running gear or sports jackets.",
"aging": {
"35": "a bit heavier, first grey in the curly hair, short beard",
"45": "grey curly hair, fuller face, glasses"
},
"anchors": [
25,
35,
45
],
"aliases": [
"David"
]
},
"lea": {
"name": "Lea",
"birthYear": 1983,
"aliases": [
"Lea"
],
"description": "Jonas' younger sister. Oval face like her brother, long dark brown hair, brown eyes, the same small gap between the front teeth, a big open laugh, dimples. Style: colourful scarves, denim jackets.",
"aging": {
"35": "hair shoulder-length, a few fine lines",
"42": "some grey strands, softer face"
},
"anchors": [
22,
35,
42
]
},
"karin": {
"name": "Karin",
"birthYear": 1955,
"aliases": [
"Karin",
"Mutter",
"Eltern"
],
"description": "Jonas' mother. Round friendly face, short grey-blonde hair, blue eyes behind reading glasses, soft double chin, a warm smile. Style: blouses, cardigans, a pearl necklace on special occasions.",
"aging": {
"65": "hair white, more lines, slightly smaller",
"70": "white hair, walks with a slight stoop, same warm smile"
},
"anchors": [
50,
65,
70
]
},
"thomas": {
"name": "Thomas",
"birthYear": 1952,
"diedYear": 2020,
"aliases": [
"Thomas",
"Vater",
"Eltern"
],
"description": "Jonas' father, a retired carpenter. Long face, thin grey hair, a grey beard, deep-set brown eyes, big hands with rough skin, a quiet serious expression that breaks into a rare smile. Style: checked shirts, wool vests.",
"aging": {
"65": "hair white and sparse, beard white",
"68": "noticeably thinner and pale, tired eyes, sitting more often"
},
"anchors": [
53,
65,
68
]
},
"cem": {
"name": "Cem",
"birthYear": 1982,
"aliases": [
"Cem"
],
"description": "Jonas' friend from Cologne, photographer. Turkish-German man, narrow face, short black hair, dark eyes, a well-kept short black beard, always a camera strap around his neck, lively expression. Style: black t-shirts, denim, a beanie.",
"aging": {
"40": "beard with grey, short hair greying at the temples"
},
"anchors": [
27,
40
]
}
},
"partners": [
{
"key": "mara",
"from": "2005-10"
}
],
"family": [
"mara",
"emil"
],
"familyNote": "Jonas' family is exactly Jonas, Mara and their only child Emil – no other children."
}

View file

@ -0,0 +1,130 @@
{
"lifeline": "lukas",
"style": "Candid amateur photo, realistic family snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
"era": {
"2012-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, 2010s clothing",
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing"
},
"characters": {
"lukas": {
"name": "Lukas",
"birthYear": 1985,
"aliases": [
"Lukas"
],
"description": "German man from the Hunsrück, former IT consultant. Narrow face, deep-set grey-green eyes, a prominent straight nose, dark blonde hair, a small chipped front tooth visible when he laughs, a mole on his right temple. Lean, wiry. Style: at 27 a tired office look (shirt, stubble), on the road sun-bleached hair, tan, a growing beard, worn t-shirts and a battered backpack; back home short hair, a trimmed beard, workshop clothes.",
"aging": {
"29": "sun-bleached longer hair, deep tan, a full blonde-brown beard, leaner",
"32": "beard trimmed, hair shorter, tanned, a few lines from the sun",
"35": "very long unkempt beard and long hair at the end of the journey, thin, weathered",
"36": "short hair and a trimmed beard again, paler, first grey hairs at the temples",
"40": "short greying hair, grey in the beard, calm settled expression, lines around the eyes"
},
"anchors": [
27,
29,
32,
35,
36,
40
]
},
"aroha": {
"name": "Aroha",
"birthYear": 1988,
"aliases": [
"Aroha"
],
"description": "Māori woman from Nelson, New Zealand. Round face, long thick black hair usually in a loose braid, dark brown eyes, a warm broad smile, a small tā moko tattoo on her wrist, a pounamu (greenstone) pendant she always wears. Style: singlets, shorts, jandals, a fleece in winter.",
"aging": {
"36": "hair with a few grey strands, fine lines, same pendant"
},
"anchors": [
26,
36
]
},
"mia": {
"name": "Mia",
"birthYear": 2016,
"birthMonth": 11,
"aliases": [
"Mia",
"Tochter"
],
"parents": [
"lukas",
"aroha"
],
"description": "Daughter of Lukas and Aroha. Round face, thick dark curly hair, her mother's dark eyes, her father's chipped-tooth grin, light brown skin, always a little pounamu pendant like her mother.",
"ageLabels": {
"0": "a newborn baby"
},
"aging": {
"0": "newborn, a few days old, dark fuzz of hair",
"2": "two-year-old toddler, dark curls, chubby cheeks",
"4": "four-year-old girl, dark curly hair in two bunches, big dark eyes",
"6": "six-year-old girl, long dark curls, missing a front tooth",
"8": "eight-year-old girl, long dark curls in a ponytail, confident grin"
},
"base": 4,
"baseDescription": "four-year-old girl, round face, thick dark curly hair in two bunches, dark brown eyes, light brown skin, cheeky grin",
"anchors": [
0,
2,
4,
6,
8
]
},
"renate": {
"name": "Renate",
"birthYear": 1955,
"aliases": [
"Renate",
"Mutter",
"Eltern"
],
"description": "Lukas' mother from a Hunsrück village. Small, round face, short grey hair, blue eyes, glasses, a worried warm smile. Style: cardigans, aprons, a rain jacket.",
"aging": {
"68": "white hair, more lines, slightly stooped"
},
"anchors": [
58,
68
]
},
"klaus": {
"name": "Klaus",
"birthYear": 1950,
"diedYear": 2023,
"aliases": [
"Klaus",
"Vater",
"Eltern"
],
"description": "Lukas' father, a retired bicycle mechanic. Tall, thin, a long face, bald with a white fringe, bushy eyebrows, big rough hands, a dry humour. Style: blue work overalls, flat cap.",
"aging": {
"67": "pale and thin after a heart attack, a walking stick",
"72": "very thin, sits more than he stands, oxygen tube sometimes"
},
"anchors": [
62,
67,
72
]
}
},
"partners": [
{
"key": "aroha",
"from": "2014-04",
"to": "2018-08"
}
],
"family": [
"aroha",
"mia"
],
"familyNote": "Lukas' family is Aroha and their only child Mia; his parents are Renate and Klaus."
}

View file

@ -0,0 +1,192 @@
{
"lifeline": "nachtschicht",
"style": "Candid amateur photo, realistic family snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
"era": {
"1981-1995": "shot on 35mm colour negative film with a compact camera: film grain, warm faded colours, slight vignetting, 1980s/early-90s clothing, cars and interiors",
"1996-2004": "shot on 35mm film or an early digital camera: slightly soft, muted colours, late-90s/early-2000s clothing and interiors",
"2005-2009": "shot on an early digital compact camera or camera phone: slightly soft, mild noise, warm tint, 2000s clothing and interiors",
"2010-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, 2010s clothing and interiors",
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
},
"familyNote": "The band Nachtschicht has exactly four members: Sven (vocals), Kai (guitar), Jule (bass) and the drummer – Basti until spring 1999, Nele from summer 1999 on. Never five members on stage. Their look evolves from shy school kids (1995) to full rock stars (tattoos, leather, long hair, mohawk around 2002–2010) to greying veterans in leather jackets (2012+).",
"characters": {
"nachtschicht": {
"name": "the band",
"birthYear": 1995,
"aliases": [
"Nachtschicht",
"Band"
],
"description": "the four-piece rock band the band",
"anchors": []
},
"sven": {
"name": "Sven",
"birthYear": 1979,
"aliases": [
"Sven"
],
"description": "Angular face, high cheekbones, intense pale blue eyes, a big expressive mouth, a scar through his left eyebrow. Skinny. Dyed black hair from 1997, tattooed arms from 2002, full beard and grey from 2012.",
"aging": {
"18": "braces gone, dyed black hair, first black clothes",
"21": "first tattoo on the forearm, black hair to the chin",
"23": "tattooed arms, long black hair, lean, black sleeveless shirt",
"26": "full sleeve tattoos, tattoos reaching up the neck, long black hair, black sleeveless shirt, silver rings",
"29": "same tattoos, long black hair, leaner face, black shirt",
"33": "full beard, tattoos faded, first grey in hair and beard, black leather jacket over a t-shirt",
"39": "grey beard and hair, tattoos faded, lines, calm",
"46": "grey, short hair, grey beard, leather jacket, lined face"
},
"anchors": [
16,
18,
21,
23,
26,
29,
33,
39,
46
],
"adultBase": 21
},
"kai": {
"name": "Kai",
"birthYear": 1979,
"aliases": [
"Kai"
],
"description": "Round face, brown eyes, thick dark eyebrows, a wide grin with a chipped front tooth. Look: 1995 acne, short brown hair, band t-shirt; from 2000 shoulder-length brown hair; 2002–2010 long brown hair, leather jacket or vest, sunglasses on stage; from 2012 short grey hair, reading glasses, cardigans, a producer look.",
"aging": {
"18": "acne gone, hair to the ears, black t-shirts",
"21": "shoulder-length brown hair, first stubble",
"23": "long brown hair, leather jacket",
"26": "long brown hair, leather vest, sunglasses, a bit heavier",
"29": "long hair with first grey, beard",
"33": "short grey hair, reading glasses, cardigan",
"39": "short grey hair, glasses, fuller face",
"46": "grey, glasses, grey stubble"
},
"anchors": [
16,
18,
21,
23,
26,
29,
33,
39,
46
],
"adultBase": 21
},
"jule": {
"name": "Jule",
"birthYear": 1980,
"aliases": [
"Jule"
],
"description": "Heart-shaped face, freckles, green eyes, a small nose, a dry half-smile. Look: 1995 two plaits, dungarees; from 1997 short dyed red hair; from 2002 one side of the head shaved, dark red hair, black tank tops, tattoo of a bass clef on the neck; from 2012 short dark red hair with grey roots, black shirts, leather jacket.",
"aging": {
"17": "short dyed red hair, black clothes",
"20": "short red hair, nose ring",
"22": "one side of the head shaved, dark red hair, neck tattoo, black tank top",
"25": "shaved side, dark red hair, more tattoos on the arms",
"28": "shaved side, dark red hair, a young mother, tired eyes",
"32": "short dark red hair with grey roots, black shirt",
"38": "short hair mostly grey, glasses on a cord",
"45": "short grey hair, lined face, leather jacket"
},
"anchors": [
15,
17,
20,
22,
25,
28,
32,
38,
45
],
"adultBase": 20
},
"basti": {
"name": "Basti",
"birthYear": 1979,
"aliases": [
"Basti"
],
"description": "Broad face, curly blonde hair, blue eyes, a friendly open grin. Look: band t-shirts, cargo shorts.",
"aging": {
"21": "curly blonde hair longer, a goatee"
},
"anchors": [
16,
21
]
},
"nele": {
"name": "Nele",
"birthYear": 1982,
"aliases": [
"Nele"
],
"description": "Long narrow face, dark eyes, a strong jaw, a nose ring. Look: 2001 shaved sides with a dark fringe, nose ring, wife-beater; 2002–2010 a blonde mohawk, tattooed shoulders, drumsticks always in hand; from 2012 short grey-blonde hair, plain black clothes.",
"aging": {
"20": "blonde mohawk, tattooed shoulders",
"23": "tall blonde mohawk, more tattoos, muscular arms",
"26": "mohawk, muscular, focused",
"30": "mohawk shorter, first grey",
"33": "short grey-blonde hair, black shirt",
"39": "short grey hair, glasses",
"43": "short grey hair, lined face, leather jacket"
},
"anchors": [
19,
20,
23,
26,
30,
33,
39,
43
]
},
"mutter": {
"name": "Svens Mutter",
"birthYear": 1950,
"aliases": [
"Svens Mutter",
"Mutter",
"Band-Mütter"
],
"description": "Sven's mother, a warm round-faced woman with short grey hair, glasses, a cardigan, always slightly worried and very proud.",
"aging": {
"62": "white hair, more lines",
"75": "white hair, stooped, glasses on a chain"
},
"anchors": [
45,
62,
75
]
}
},
"partners": [],
"family": [],
"band": [
"sven",
"kai",
"jule"
],
"bandDrummer": [
{
"key": "basti",
"to": "1999-05"
},
{
"key": "nele",
"from": "1999-07"
}
]
}

View file

@ -0,0 +1,44 @@
{
"lifeline": "nadja",
"style": "Candid amateur photo, realistic snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
"era": {
"2012-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, early-2010s clothing and interiors",
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
},
"characters": {
"nadja": {
"name": "Nadja",
"birthYear": 1988,
"description": "German woman from Hamburg. Long face with high cheekbones, straight dark blonde hair usually worn in a loose bun or ponytail, grey-blue eyes, slightly hooked nose, a small scar through her left eyebrow, thin lips, a wide confident smile. Tall and slim. Style in her twenties: smart-casual event manager (blazers, blouses, statement earrings); later: work clothes, flannel shirts, hair often tied up with a pencil, hands with small cuts and calluses.",
"aging": {
"29": "slightly more angular face, hair a bit longer, still no grey, a more tired look around the eyes",
"34": "hair cut to shoulder length and worn down or in a short ponytail, first fine lines around the eyes, tanned and healthier looking, no make-up",
"37": "a few grey strands at the temples, fine lines around eyes and mouth, calm settled expression, short practical haircut"
},
"anchors": [24, 29, 34, 37]
},
"tim": {
"name": "Tim",
"birthYear": 1987,
"description": "German man, Nadja's boyfriend and later husband, later ex-husband. Square jaw, short light brown hair styled up at the front, blue eyes, a straight nose, a dimple in his chin, a charming easy grin, tall and athletic. Style: fitted shirts with rolled sleeves, chinos, sneakers, a big wristwatch.",
"aging": {
"32": "slightly fuller face, hair a little thinner at the front, tired eyes"
},
"anchors": [25, 32]
},
"kim": {
"name": "Kim",
"birthYear": 1989,
"description": "Nadja's best friend, Vietnamese-German woman from Hamburg. Round face, long black hair often in a messy bun, dark brown eyes, small nose, a big warm laugh, a nose stud. Short. Style: oversized sweaters, colourful socks, big glasses with round frames.",
"aging": {},
"anchors": [31]
},
"ben": {
"name": "Ben",
"birthYear": 1985,
"description": "German man from Lüneburg, Nadja's later partner. Broad friendly face, short reddish-brown hair and a full reddish beard, hazel eyes, laugh lines, sunburnt freckled skin, a gap-toothed easy smile. Stocky. Style: t-shirts, shorts, outdoor jackets, a cap.",
"aging": {},
"anchors": [38]
}
}
}

View file

@ -0,0 +1,182 @@
{
"lifeline": "sophie",
"style": "Candid amateur photo, realistic snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
"era": {
"2005-2009": "shot on an early digital compact camera or first-generation camera phone: slightly soft, mild noise, warm tint, small flash reflections, early-2000s clothing and interiors",
"2010-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, 2010s clothing and interiors",
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
},
"characters": {
"sophie": {
"name": "Sophie",
"birthYear": 1980,
"aliases": [
"Sophie"
],
"description": "German woman from Leipzig, cultural manager and later author. Round soft face, chin-length dark blonde hair with a side fringe, warm brown eyes, a small upturned nose, a beauty mark above the right corner of her mouth, a wide warm smile with slightly crooked front teeth. Medium height, a little curvy. Style: colourful blouses, cardigans, big scarves, tortoiseshell glasses from her mid-thirties on.",
"aging": {
"30": "hair a bit longer, face slightly slimmer",
"35": "tortoiseshell glasses, first fine lines around the eyes, hair shoulder-length",
"40": "shoulder-length hair with a few grey strands, laugh lines, glasses",
"45": "chin-length hair with visible grey, lines around eyes and mouth, glasses, calm content expression"
},
"anchors": [
25,
30,
35,
40,
45
]
},
"felix": {
"name": "Felix",
"birthYear": 1979,
"aliases": [
"Felix"
],
"description": "Sophie's first boyfriend, a touring musician. Thin face, shoulder-length dark wavy hair, grey eyes, stubble, a silver ring in his left ear, a distant dreamy look. Tall and lanky. Style: band t-shirts, leather jacket.",
"aging": {},
"anchors": [
26
]
},
"alina": {
"name": "Alina",
"birthYear": 1981,
"aliases": [
"Alina"
],
"description": "Sophie's best friend and former flatmate. Heart-shaped face, very short red hair, green eyes, freckles everywhere, a wide grin, a small nose piercing. Petite. Style: dungarees, big earrings, later a café apron.",
"aging": {
"35": "hair a bit longer, still red, a few lines",
"45": "red hair with grey roots, more freckles and lines, same grin"
},
"anchors": [
25,
35,
45
]
},
"nina": {
"name": "Nina",
"birthYear": 1982,
"aliases": [
"Nina"
],
"description": "Sophie's partner and later wife. Long narrow face, long straight black hair usually in a plait, dark brown almond-shaped eyes, a straight nose, a serious face that softens into a shy smile, a small tattoo of a swallow on her left wrist. Slim, tall. Style: plain dark clothes, white shirts, a leather jacket.",
"aging": {
"35": "hair in a bun more often, first fine lines",
"45": "a few grey strands in the black hair, lines around the eyes, same plait"
},
"anchors": [
27,
35,
45
]
},
"daniel": {
"name": "Daniel",
"birthYear": 1977,
"aliases": [
"Daniel"
],
"description": "Sophie's older brother. Round face like his sister, short brown hair thinning early, brown eyes, a goatee, a friendly tired look, a bit overweight. Style: polo shirts, fleece jackets.",
"aging": {
"40": "bald on top, goatee with grey",
"48": "bald, grey goatee, glasses"
},
"anchors": [
30,
40,
48
]
},
"petra": {
"name": "Petra",
"birthYear": 1953,
"aliases": [
"Petra",
"Mutter",
"Eltern"
],
"description": "Sophie's mother from Erfurt, a teacher. Soft round face, short wavy grey hair, brown eyes, a beauty mark like her daughter's, glasses on a chain, a warm energetic smile. Style: colourful blouses, long skirts.",
"aging": {
"65": "hair white, more lines, a little smaller",
"72": "white hair, slower, slightly stooped, same smile"
},
"anchors": [
55,
65,
72
]
},
"michael": {
"name": "Michael",
"birthYear": 1950,
"aliases": [
"Michael",
"Vater",
"Eltern"
],
"description": "Sophie's father from Erfurt, retired engineer. Long face, bald with a white fringe of hair, bushy white eyebrows, blue eyes, a big nose, a dry half-smile. Tall, thin. Style: shirts with sweater vests, a hat outdoors.",
"aging": {
"68": "thinner, walks with a stick after knee surgery",
"75": "very thin, stooped, hearing aid, still the dry smile"
},
"anchors": [
58,
68,
75
]
},
"clara": {
"name": "Clara",
"birthYear": 2015,
"aliases": [
"Clara"
],
"parents": [
"nina"
],
"description": "Daughter of Nina and Sophie (Nina carried her). Nina's long face and dark almond eyes, dark brown hair, a small nose, a serious thoughtful look that turns into a sudden big laugh.",
"ageLabels": {
"0": "a newborn baby",
"1": "a one-year-old baby"
},
"aging": {
"0": "newborn, a few days old, wrinkled pink skin, dark fuzz of hair, eyes closed",
"1": "one-year-old baby, chubby cheeks, dark hair, big dark eyes",
"3": "three-year-old girl, round face, dark brown hair in a short bob, dark almond eyes",
"7": "seven-year-old girl, slimmer face, long dark brown hair in a ponytail, missing a front tooth",
"10": "ten-year-old girl, long dark hair, serious dark eyes, headphones often around her neck, a teenager's guarded look starting"
},
"base": 5,
"baseDescription": "five-year-old girl, long face, dark brown hair in two plaits, dark almond-shaped eyes, small nose, serious thoughtful look",
"anchors": [
0,
1,
3,
5,
7,
10
],
"birthMonth": 1
}
},
"partners": [
{
"key": "felix",
"from": "2005-01",
"to": "2007-01"
},
{
"key": "nina",
"from": "2008-07"
}
],
"family": [
"nina",
"clara"
],
"familyNote": "Sophie's family is exactly Sophie, her wife Nina and their only child Clara – no other children, no man in the family."
}

View file

@ -0,0 +1,96 @@
{
"lifeline": "werner",
"style": "Candid amateur photo, realistic family snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
"era": {
"2010-2015": "shot on a cheap digital compact camera or early smartphone: slightly flat colours, mild noise, early-2010s clothing and interiors",
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
},
"characters": {
"werner": {
"name": "Werner",
"birthYear": 1955,
"description": "German man from Dortmund, retired steel worker and works council member. Broad weathered face, deep-set pale blue eyes under bushy grey eyebrows, big nose, thin grey hair combed back over a high forehead, a trimmed grey moustache, large hands, a small scar on his chin. Sturdy, slightly overweight, a bit stooped in later years. Style: checked short-sleeve shirts, cardigans, a flat cap outdoors, later a walking stick.",
"aging": {
"60": "hair thinner and whiter, moustache white, deeper lines, slightly heavier",
"65": "noticeably thinner face after his wife's illness, hair mostly white and sparse, tired eyes, grey stubble",
"70": "white hair and moustache, a bit stooped, reading glasses on a cord around his neck, warmer, more relaxed expression"
},
"anchors": [
55,
60,
65,
70
]
},
"renate": {
"name": "Renate",
"birthYear": 1957,
"description": "Werner's wife, German woman from Dortmund. Round soft face, short curly hair dyed a warm auburn, brown eyes, rosy cheeks, dimples, small gold stud earrings and reading glasses pushed up into her hair. Plump and cheerful. Style: floral blouses, cardigans, an apron at home.",
"aging": {
"60": "hair a little greyer at the roots, same warm smile",
"63": "very short grey hair growing back after chemotherapy, thinner face, pale but smiling, often a knitted hat"
},
"anchors": [
53,
60,
63
]
},
"anke": {
"name": "Anke",
"birthYear": 1982,
"description": "Werner's daughter. Her mother's round face and dimples, her father's pale blue eyes, dark blonde hair in a practical ponytail, a warm tired smile, a mother of two. Style: jeans, fleece jackets, sneakers.",
"aging": {
"40": "a few grey strands, fine lines around the eyes"
},
"anchors": [
28,
40
]
},
"juergen": {
"name": "Jürgen",
"birthYear": 1985,
"description": "Werner's son, lives in Canada. Tall, his father's big nose and pale blue eyes, short brown hair, a full brown beard, outdoorsy tan. Style: plaid shirts, down vests.",
"aging": {
"38": "beard with some grey, slightly heavier"
},
"anchors": [
27,
38
]
},
"lina": {
"name": "Lina",
"birthYear": 2010,
"description": "Werner's granddaughter, Anke's daughter. Round face, her grandmother's dimples, straight light blonde hair, pale blue eyes, a gap between the front teeth as a child, later braces, a serious clever look.",
"aging": {
"0": "newborn, a few days old, wrinkled pink skin, fine blonde fuzz, eyes closed",
"6": "six-year-old girl, round face, dimples, long light blonde hair in two plaits, gap between the front teeth",
"11": "eleven-year-old girl, slimmer face, long blonde hair in a ponytail, dimples, braces",
"15": "fifteen-year-old teenager, shoulder-length blonde hair, dimples, no braces any more, confident look"
},
"base": 6,
"baseDescription": "six-year-old girl, round face, dimples, long light blonde hair in two plaits, pale blue eyes, gap between the front teeth, serious clever look",
"anchors": [
0,
6,
11,
15
],
"parents": [
"anke"
],
"birthMonth": 8
},
"hannelore": {
"name": "Hannelore",
"birthYear": 1958,
"description": "Werner's later partner from the hiking club. Slim, upright posture, a narrow face with high cheekbones, short silver-white hair in a neat modern cut, grey-green eyes, thin-rimmed glasses, a calm friendly smile. Style: outdoor jackets, scarves, hiking boots, small pearl earrings.",
"aging": {},
"anchors": [
65
]
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,630 @@
{
"person": {
"name": "Lukas",
"email": "lukas@thats-me.app",
"birthYear": 1985
},
"events": [
{
"age": 27,
"month": 2,
"title": "Zusammenbruch im Büro",
"note": "Ein Kollege fotografiert Lukas im Frankfurter Großraumbüro, der mit dem Kopf auf den Armen am Schreibtisch liegt, drei Monitore voller Tabellen.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ"
},
{
"age": 27,
"month": 4,
"title": "Kündigung",
"note": "Ein Selfie im Fahrstuhl mit einem Karton voller Bürosachen und einem Umschlag, Lukas mit leerem, aber ruhigem Blick.",
"area": "Persönliche Entscheidung",
"photo": "Selfie",
"rating": "Neutral"
},
{
"age": 27,
"month": 5,
"title": "Wohnung aufgelöst",
"note": "Ein Selfie in der leeren Wohnung, nur ein Rucksack in der Mitte des Raums, Lukas sitzt daneben auf dem Boden.",
"area": "Wohnen",
"photo": "Selfie",
"rating": "Neutral"
},
{
"age": 27,
"month": 6,
"title": "One-Way-Ticket",
"note": "Ein Selfie am Gate in Frankfurt mit dem Boarding Pass (unleserlich), Lukas mit riesigem Rucksack und ungläubigem Grinsen.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Sehr positiv"
},
{
"age": 27,
"month": 7,
"title": "Bangkok, erste Nacht",
"note": "Ein Selfie in einer neonbeleuchteten Straße in Bangkok, Lukas verschwitzt mit einem Bierchen zwischen Garküchen.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Positiv"
},
{
"age": 27,
"month": 8,
"title": "Motorroller in Laos",
"note": "Ein Reisender fotografiert Lukas auf einem alten Roller auf einer roten Staubpiste in Laos, Reisfelder, Wasserbüffel am Straßenrand.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 27,
"month": 9,
"title": "Angkor Wat",
"note": "Ein Mönch fotografiert Lukas bei Sonnenaufgang vor den Türmen von Angkor Wat in Kambodscha, die Silhouette spiegelt sich im Wasserbecken, Lukas barfuß mit Rucksack.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 27,
"month": 10,
"title": "Vietnam, Halong-Bucht",
"note": "Ein Selfie auf einem Holzboot in der Halong-Bucht, Kalksteinfelsen im Nebel, Lukas mit Reisstrohhut.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Positiv"
},
{
"age": 27,
"month": 12,
"title": "Weihnachten im Hostel",
"note": "Eine Hostelbesitzerin fotografiert Lukas mit zwölf Backpackern aus acht Ländern beim improvisierten Weihnachtsessen in Hanoi.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 28,
"month": 1,
"title": "Taj Mahal",
"note": "Ein Tourguide fotografiert Lukas auf der berühmten Bank vor dem Taj Mahal in Agra, Morgennebel, ein Affe auf der Mauer neben ihm.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 28,
"month": 2,
"title": "Indien, Varanasi",
"note": "Ein Selfie bei Sonnenaufgang auf den Ghats von Varanasi, hinter Lukas Boote auf dem Ganges und Rauch, sein Blick ernst.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Neutral"
},
{
"age": 28,
"month": 3,
"title": "Zugfahrt durch Rajasthan",
"note": "Ein Mitreisender fotografiert Lukas in der offenen Tür eines fahrenden Zuges in Rajasthan, Wüstenlandschaft, Wind im Haar.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 28,
"month": 4,
"title": "Krank in Delhi",
"note": "Ein Selfie in einem heruntergekommenen Hostelbett in Delhi, Lukas abgemagert und bleich, ein Ventilator, eine Infusionsflasche am Bettpfosten.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Sehr negativ"
},
{
"age": 28,
"month": 5,
"title": "Nepal, Annapurna",
"note": "Ein Trekkingpartner fotografiert Lukas am Thorong-La-Pass in 5.400 Metern Höhe, Gebetsfahnen, Schnee, ein müdes, strahlendes Gesicht.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 28,
"month": 7,
"title": "Anruf nach Hause",
"note": "Ein Selfie in einem Internetcafé in Kathmandu, Lukas mit Headset beim Videoanruf, auf dem Bildschirm verschwommen seine Eltern.",
"area": "Familie",
"photo": "Selfie",
"rating": "Neutral"
},
{
"age": 28,
"month": 9,
"title": "Australien, Farmarbeit",
"note": "Ein Farmer fotografiert Lukas mit Strohhut und Kisten voller Mangos auf einer Plantage in Queensland, Schweiß, Sonnenbrand.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 28,
"month": 10,
"title": "Great Barrier Reef",
"note": "Ein Tauchlehrer fotografiert Lukas unter Wasser am Great Barrier Reef mit Taucherbrille und Schnorchel, um ihn herum bunte Korallen und ein Schwarm gelber Fische.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 28,
"month": 11,
"title": "Roadtrip Outback",
"note": "Ein Foto vor einem rostigen Kombi mitten im Outback, Lukas und zwei Freunde auf dem Dach, roter Sand bis zum Horizont, Sonnenuntergang.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 28,
"month": 12,
"title": "Uluru",
"note": "Ein Mitreisender fotografiert Lukas bei Sonnenuntergang vor dem rot leuchtenden Uluru, er sitzt auf der Motorhaube des Kombis mit einer Dose Bier.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 29,
"month": 1,
"title": "Silvester Sydney",
"note": "Ein Selfie am Hafen von Sydney mit dem Feuerwerk über der Harbour Bridge im Hintergrund, Lukas zwischen fremden feiernden Menschen.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Positiv"
},
{
"age": 29,
"month": 3,
"title": "Fähre nach Neuseeland",
"note": "Ein Selfie an der Reling einer Fähre, graues Meer, Lukas mit Kapuze, Blick auf die Küste der Südinsel.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Neutral"
},
{
"age": 29,
"month": 4,
"title": "Aroha",
"note": "Eine Kellnerin fotografiert Lukas und Aroha, eine junge Māori-Frau, beim ersten gemeinsamen Kaffee in einem Café in Nelson, beide verlegen lachend.",
"area": "Liebe",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 29,
"month": 5,
"title": "Milford Sound",
"note": "Aroha fotografiert Lukas im Kajak im Milford Sound, steile Felswände mit Wasserfällen im Nebel, ein Delfin neben dem Boot.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 29,
"month": 6,
"title": "Arbeit auf dem Fischerboot",
"note": "Ein Fischer fotografiert Lukas in Ölzeug auf einem Fischkutter vor der Küste, eine Kiste Muscheln in den Händen, Möwen.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 29,
"month": 8,
"title": "Arohas Familie",
"note": "Arohas Mutter fotografiert Lukas beim Hāngi, einem Erdofenfest, mit Arohas großer Familie, Lukas hilft beim Ausgraben der Speisen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 29,
"month": 11,
"title": "Das Haus am Meer",
"note": "Ein Selfie von Lukas und Aroha vor einem kleinen Holzhaus mit Blick auf die Tasman Bay, ein Schlüssel in Arohas Hand.",
"area": "Wohnen",
"photo": "Selfie",
"rating": "Sehr positiv"
},
{
"age": 30,
"month": 2,
"title": "30. Geburtstag am Strand",
"note": "Aroha fotografiert Lukas am Lagerfeuer am Strand zu seinem 30. Geburtstag, barfuß, mit Gitarre, Freunde im Halbkreis.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 30,
"month": 7,
"title": "Eltern zu Besuch",
"note": "Aroha fotografiert Lukas mit seinen Eltern Renate und Klaus am Flughafen Nelson, Klaus mit Hut, Renate weint und lacht.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 30,
"month": 10,
"title": "Visum-Sorgen",
"note": "Ein Selfie am Küchentisch mit einem Stapel Formulare (unleserlich), Lukas mit angespanntem Blick, Aroha im Hintergrund am Telefon.",
"area": "Alltag",
"photo": "Selfie",
"rating": "Negativ"
},
{
"age": 31,
"month": 3,
"title": "Aroha ist schwanger",
"note": "Ein Selfie von Lukas und Aroha am Strand, Aroha hält ein Ultraschallbild in die Kamera, beide überwältigt.",
"area": "Familie",
"photo": "Selfie",
"rating": "Sehr positiv"
},
{
"age": 31,
"month": 6,
"title": "Eigenes Boot",
"note": "Ein Freund fotografiert Lukas beim Streichen des Rumpfs eines kleinen gebrauchten Fischerboots im Trockendock, der Name noch nicht aufgemalt.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 31,
"month": 11,
"title": "Mia wird geboren",
"note": "Eine Hebamme fotografiert Lukas mit seiner neugeborenen Tochter Mia im Arm im Geburtshaus, Aroha im Bett daneben erschöpft und glücklich.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 32,
"month": 2,
"title": "Erste Nächte",
"note": "Ein übermüdetes Selfie um vier Uhr morgens mit Mia auf der Brust, das Meer im Fenster.",
"area": "Familie",
"photo": "Selfie",
"rating": "Neutral"
},
{
"age": 32,
"month": 5,
"title": "Klaus' Herzinfarkt",
"note": "Ein Selfie am Flughafen Auckland, Lukas allein mit kleinem Rucksack, verzweifelter Blick, auf dem Weg nach Deutschland.",
"area": "Familie",
"photo": "Selfie",
"rating": "Sehr negativ"
},
{
"age": 32,
"month": 6,
"title": "Am Krankenbett in Koblenz",
"note": "Renate fotografiert Lukas an Klaus' Krankenbett in Koblenz, Vater und Sohn halten sich die Hand, Klaus blass mit Schläuchen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 32,
"month": 8,
"title": "Rückflug mit schlechtem Gewissen",
"note": "Ein Selfie im Flugzeugfenster über den Wolken, Lukas mit geschlossenen Augen, zwischen zwei Welten.",
"area": "Familie",
"photo": "Selfie",
"rating": "Negativ"
},
{
"age": 33,
"month": 1,
"title": "Streit um die Zukunft",
"note": "Ein Selfie auf der Veranda des Hauses am Meer, Lukas allein, im Hintergrund Aroha mit Mia auf dem Arm, die ins Haus geht.",
"area": "Partnerschaft",
"photo": "Selfie",
"rating": "Negativ"
},
{
"age": 33,
"month": 4,
"title": "Mias erster Geburtstag",
"note": "Arohas Mutter fotografiert Mia mit einem kleinen Kuchen, Lukas und Aroha jeweils auf einer Seite, beide lächeln, aber nicht zueinander.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 33,
"month": 9,
"title": "Trennung",
"note": "Ein Selfie in einer kleinen Mietwohnung in Nelson, Lukas mit Mias Kinderzeichnung an der Wand und einem Feldbett.",
"area": "Partnerschaft",
"photo": "Selfie",
"rating": "Sehr negativ"
},
{
"age": 34,
"month": 2,
"title": "Wochenend-Papa",
"note": "Eine Passantin fotografiert Lukas und Mia, 2, beim Muschelnsuchen am Strand, Lukas hockt neben ihr, beide konzentriert.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 34,
"month": 6,
"title": "Südamerika, Flucht nach vorn",
"note": "Ein Selfie am Flughafen Santiago de Chile, Lukas mit dem alten Rucksack, entschlossen, aber müde.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Neutral"
},
{
"age": 34,
"month": 7,
"title": "Rio de Janeiro",
"note": "Ein Tourist fotografiert Lukas mit ausgebreiteten Armen vor der Christusstatue auf dem Corcovado, unter ihm die Bucht von Rio und der Zuckerhut im Dunst.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 34,
"month": 8,
"title": "Patagonien",
"note": "Ein Wanderer fotografiert Lukas vor den Türmen des Torres del Paine, Wind, Lukas' Bart ist lang geworden.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 34,
"month": 9,
"title": "Iguazú-Wasserfälle",
"note": "Ein tropfnasses Selfie auf dem Steg über dem Teufelsschlund der Iguazú-Fälle, Gischt überall, ein Regenbogen, Lukas lacht mit offenem Mund.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Sehr positiv"
},
{
"age": 34,
"month": 11,
"title": "Salar de Uyuni",
"note": "Ein Reisender fotografiert Lukas auf der spiegelnden Salzfläche von Uyuni in Bolivien, Himmel und Boden verschmelzen, Lukas springt.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 35,
"month": 2,
"title": "Machu Picchu",
"note": "Ein Wanderer fotografiert Lukas nach vier Tagen Inka-Trail auf der Terrasse über Machu Picchu, Wolken ziehen durch die Ruinen, Lukas mit langem Bart und geflicktem Rucksack.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 35,
"month": 3,
"title": "Lockdown in Cusco",
"note": "Ein Selfie in einem geschlossenen Hostel in Cusco, Lukas mit Maske am Fenster, leere Gassen, Polizei auf der Straße.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Sehr negativ"
},
{
"age": 35,
"month": 4,
"title": "Videoanruf mit Mia",
"note": "Ein Selfie mit dem Handy, auf dem Bildschirm verschwommen Mia, 3, die ihm etwas zeigt, Lukas lächelt mit feuchten Augen.",
"area": "Familie",
"photo": "Selfie",
"rating": "Negativ"
},
{
"age": 35,
"month": 7,
"title": "Rückholflug",
"note": "Ein Mitreisender fotografiert Lukas in einem fast leeren Flugzeug mit Maske, Blick aus dem Fenster auf die Anden.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 35,
"month": 9,
"title": "Zurück im Hunsrück",
"note": "Ein Selfie vor dem Elternhaus in einem Hunsrückdorf, Lukas mit dem alten Rucksack, Renate in der Tür, Klaus mit Gehstock dahinter.",
"area": "Wohnen",
"photo": "Selfie",
"rating": "Neutral"
},
{
"age": 35,
"month": 11,
"title": "Altes Kinderzimmer",
"note": "Ein Selfie im alten Kinderzimmer mit Weltkarte an der Wand, Lukas, 35, sitzt auf dem zu kleinen Bett und schaut auf die Karte.",
"area": "Wohnen",
"photo": "Selfie",
"rating": "Negativ"
},
{
"age": 36,
"month": 2,
"title": "Klaus' Werkstatt",
"note": "Renate fotografiert Lukas und Klaus in der alten Fahrradwerkstatt im Schuppen, Klaus erklärt sitzend, Lukas schraubt.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 36,
"month": 5,
"title": "Erste Kunden im Dorf",
"note": "Eine Nachbarin fotografiert Lukas vor dem Schuppen mit einem handgemalten Schild (unleserlich) und drei reparierten Fahrrädern, ein älterer Kunde bezahlt.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 36,
"month": 8,
"title": "Mia kommt zu Besuch",
"note": "Renate fotografiert Lukas am Flughafen Frankfurt, der Mia, 4, hochhebt, Aroha ein paar Schritte dahinter mit den Koffern.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 36,
"month": 9,
"title": "Drei Wochen Sommer",
"note": "Klaus fotografiert Lukas und Mia auf einem Kinderfahrrad und einem alten Herrenrad auf einem Feldweg im Hunsrück, Sonnenblumen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 36,
"month": 10,
"title": "Abschied am Gate",
"note": "Ein Selfie am Flughafen mit Mia auf dem Arm, beide mit verheulten Gesichtern, Aroha hält die Boarding Pässe.",
"area": "Familie",
"photo": "Selfie",
"rating": "Sehr negativ"
},
{
"age": 37,
"month": 1,
"title": "Winter allein",
"note": "Ein Selfie in der kalten Werkstatt bei Kerzenlicht, Lukas mit Mütze und Mias Zeichnung an der Wand hinter ihm.",
"area": "Persönliche Entwicklung",
"photo": "Selfie",
"rating": "Negativ"
},
{
"age": 37,
"month": 4,
"title": "Werkstatt wird Laden",
"note": "Ein Freund fotografiert Lukas beim Einbau eines großen Fensters in den alten Schuppen, Fahrräder hängen an der Wand, Klaus schaut vom Stuhl aus zu.",
"area": "Selbstständigkeit",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 37,
"month": 7,
"title": "Dorffest",
"note": "Ein Foto vom Dorffest: Lukas am Bierzelttisch mit Nachbarn, ein alter Schulfreund legt ihm den Arm um die Schulter, Lampions.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 37,
"month": 9,
"title": "Mia, zweiter Sommer",
"note": "Renate fotografiert Lukas und Mia, 5, die in der Werkstatt gemeinsam ein kleines Kinderfahrrad in leuchtendem Türkis lackieren.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 38,
"month": 3,
"title": "Klaus stirbt",
"note": "Ein Foto vom Dorffriedhof im März, Lukas und Renate Arm in Arm hinter dem Sarg, das halbe Dorf dahinter.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ"
},
{
"age": 38,
"month": 6,
"title": "Renate zieht in die Werkstatt-Wohnung",
"note": "Ein Nachbar fotografiert Lukas, der Renates Sessel in die kleine Wohnung über der Werkstatt trägt, Renate dirigiert von unten.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 38,
"month": 8,
"title": "Aroha bringt Mia",
"note": "Renate fotografiert Lukas und Aroha, die vor der Werkstatt zusammen Kaffee trinken, Mia, 6, fährt Kreise um sie herum, beide entspannt.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 39,
"month": 5,
"title": "Radtour-Gruppe",
"note": "Ein Teilnehmer fotografiert Lukas als Guide einer Gruppe von acht Radfahrern vor der Werkstatt, Lukas mit Karte, alle mit Helmen.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 39,
"month": 9,
"title": "Mia lernt Deutsch",
"note": "Renate fotografiert Lukas und Mia, 7, die am Küchentisch über einem Kinderbuch sitzen, Mia liest laut, Lukas hält sich das Lachen zurück.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 40,
"month": 2,
"title": "40. Geburtstag",
"note": "Renate fotografiert Lukas zu seinem 40. in der Werkstatt, Freunde aus dem Dorf mit Bier, an der Wand hängt die alte Weltkarte aus dem Kinderzimmer mit Stecknadeln.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 40,
"month": 7,
"title": "Mia bleibt einen Monat",
"note": "Ein Nachbar fotografiert Lukas und Mia, 8, die auf dem Dach der Werkstatt sitzen und über das Dorf schauen, Mia mit Lukas' altem Rucksack als Kissen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 40,
"month": 10,
"title": "Flug nach Nelson gebucht",
"note": "Ein Selfie am Laptop mit einer Flugbuchung auf dem Bildschirm (verschwommen), Lukas mit ruhigem Lächeln, daneben eine Postkarte von der Tasman Bay.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Positiv"
},
{
"age": 40,
"month": 12,
"title": "Zwei Zuhause",
"note": "Ein Selfie an der Werkstatttür im Schnee, Lukas mit Mütze, in der Hand eine gerahmte Zeichnung von Mia: ein Haus am Meer und ein Haus mit Fahrrädern, dazwischen ein Flugzeug.",
"area": "Persönliche Entwicklung",
"photo": "Selfie",
"rating": "Sehr positiv"
}
]
}

View file

@ -0,0 +1,553 @@
{
"person": {
"name": "Nachtschicht (Band)",
"email": "nachtschicht@thats-me.app",
"birthYear": 1995
},
"events": [
{
"age": 0,
"month": 3,
"title": "Erste Probe im Keller",
"note": "Svens Mutter fotografiert vier 16-Jährige in Schulkapuzenpullis und Turnschuhen im Heizungskeller eines Reihenhauses in Gelsenkirchen: Sven mit Zahnspange am Mikro, Kai mit Pickeln und geliehener Gitarre, Jule mit Zöpfen am Bass, Basti hinter einem Schlagzeug mit Eierkartons an der Wand.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 0,
"month": 6,
"title": "Der Name",
"note": "Kai fotografiert Sven, der mit Edding „Nachtschicht“ auf einen alten Bettbezug schreibt, Jule und Basti halten den Stoff, alle diskutieren.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 0,
"month": 11,
"title": "Erster Auftritt, Schulaula",
"note": "Ein Lehrer fotografiert die Band auf der Bühne der Schulaula beim Herbstfest, brave Schülerkleidung, Sven mit geschlossenen Augen am Mikro, vor der Bühne etwa dreißig Schüler, Jule mit verlegenem Grinsen.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 1,
"month": 4,
"title": "Demo-Kassette",
"note": "Basti fotografiert die vier auf Jules Bett, jeder hält eine kopierte Kassette mit handgeschriebenem Cover, dahinter ein Stapel von fünfzig Stück.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 1,
"month": 9,
"title": "Jugendzentrum",
"note": "Ein Zuschauer fotografiert die Band im Jugendzentrum: verschwitzt, Sven springt, Kai auf dem Boden mit dem Rücken zum Publikum, etwa hundert Leute.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 2,
"month": 7,
"title": "Bandbus",
"note": "Bastis Vater fotografiert die vier vor einem verrosteten roten VW T3 mit Schiebetür, Kai auf dem Dach, Jule mit dem Schlüssel, Sven küsst die Motorhaube.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 2,
"month": 10,
"title": "Bandcontest Bochum",
"note": "Ein Fotograf der Lokalzeitung fotografiert die Band mit einem Pokal auf der Bühne eines Clubs in Bochum, erste schwarze Klamotten, Sven mit gefärbten Haaren, Basti hebt Jule hoch, Sven und Kai umarmen sich.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 3,
"month": 2,
"title": "Abitur und Streit",
"note": "Jule fotografiert Sven und Basti, die im Proberaum mit dem Rücken zueinander sitzen, dazwischen ein Studienführer und ein Tourplan.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 3,
"month": 8,
"title": "Erste eigene EP",
"note": "Ein Tontechniker fotografiert die Band in einem kleinen Studio in Dortmund hinter dem Mischpult, alle lauschen mit geschlossenen Augen der Aufnahme.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 4,
"month": 5,
"title": "Basti geht",
"note": "Kai fotografiert Basti, der seine Drumsticks auf die Snare legt und den Proberaum verlässt, Sven sitzt auf dem Verstärker und schaut zu Boden.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ"
},
{
"age": 4,
"month": 7,
"title": "Nele",
"note": "Jule fotografiert Nele, eine 19-jährige Schlagzeugerin mit rasierten Seiten, beim Vorspielen im Proberaum, Sven und Kai mit offenem Mund.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 4,
"month": 10,
"title": "Erste Tour, sieben Städte",
"note": "Ein Fan fotografiert die Band nachts an einer Autobahnraststätte vor dem T3, alle mit Pommes, Nele schläft auf dem Bordstein.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 5,
"month": 3,
"title": "Plattenvertrag",
"note": "Ein Labelmitarbeiter fotografiert die vier an einem Konferenztisch in Berlin, Sven mit frischem Tattoo am Unterarm unterschreibt einen Vertrag (unleserlich), Kai mit schulterlangen Haaren hält die Faust hoch, Jule mit Sektglas, Nele mit Nasenring.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 5,
"month": 11,
"title": "Studio in Hamburg",
"note": "Ein Produzent fotografiert Sven allein in der Gesangskabine um drei Uhr nachts, sichtlich fertig, Notizzettel überall.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 6,
"month": 4,
"title": "Erstes Album",
"note": "Ein Plattenladenbesitzer fotografiert die Band vor einem Regal mit ihrem ersten Album in einem Plattenladen in Gelsenkirchen, Kai küsst eine CD.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 6,
"month": 9,
"title": "Radio-Hit",
"note": "Ein Kellner fotografiert die vier in einer Kneipe, wie sie am Radio hinter dem Tresen ihren eigenen Song hören, Jule hält sich die Ohren zu vor Aufregung.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 7,
"month": 2,
"title": "Support-Tour Europa",
"note": "Ein Roadie fotografiert die Band im Backstagebereich einer großen Halle in Paris, alle sitzen auf Flightcases, Neles Sticks im Mund.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 7,
"month": 6,
"title": "Erste große Bühne",
"note": "Ein Festivalfotograf fotografiert die Band von der Bühnenseite bei einem Festival, vor ihnen ein Meer von zehntausend Menschen im Abendlicht, Sven ohne Hemd mit ausgebreiteten Armen, Kai in Lederjacke mit Gitarre über dem Kopf, Nele mit blondem Irokesenschnitt.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 7,
"month": 12,
"title": "Sven verliert die Stimme",
"note": "Kai fotografiert Sven im Wartezimmer eines HNO-Arztes mit einem Schal um den Hals, ein Zettel mit „Nicht sprechen“ (unleserlich) in der Hand.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 8,
"month": 5,
"title": "Zweites Album floppt",
"note": "Ein Labelmitarbeiter fotografiert die vier in einem Besprechungsraum mit Verkaufszahlen an der Wand (unleserlich), lange Gesichter, Jule schaut aus dem Fenster.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 8,
"month": 9,
"title": "Kai will aufhören",
"note": "Nele fotografiert Kai, der seine Gitarre im Proberaum in den Koffer packt, Sven steht in der Tür mit einer Hand am Rahmen.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ"
},
{
"age": 8,
"month": 11,
"title": "Die Aussprache",
"note": "Jule fotografiert Sven und Kai um fünf Uhr morgens auf einer Bank an der Emscher, beide mit Kaffeebechern, nach einer Nacht Reden.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 9,
"month": 3,
"title": "Neuanfang im Keller",
"note": "Svens Mutter fotografiert die Band wieder im Heizungskeller von damals, jetzt mit besseren Instrumenten, aber denselben Eierkartons.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 9,
"month": 10,
"title": "Das dritte Album",
"note": "Ein Tontechniker fotografiert die vier, die in einem Studio um ein einzelnes Mikrofon stehen und den letzten Refrain gemeinsam einsingen.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 10,
"month": 4,
"title": "Nummer eins",
"note": "Ein Fan fotografiert die Band, die in einem Elektromarkt vor einer Wand aus Fernsehern steht, auf allen läuft ihr Video, Nele mit Tränen in den Augen.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 10,
"month": 8,
"title": "Rock am Ring",
"note": "Ein Festivalfotograf fotografiert die Band von hinten auf der Hauptbühne von Rock am Ring, vor ihnen die Menge bis zum Horizont, Pyros, Sven mit tätowierten Armen und Rücken, Kai mit langen Haaren und Lederweste, Jule mit rasierter Seite und Bass über der Schulter, Nele hinter einem riesigen Schlagzeug.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 10,
"month": 12,
"title": "Preisverleihung",
"note": "Ein Pressefotograf fotografiert die vier in Abendgarderobe mit einem Musikpreis auf einem roten Teppich, Kai hat seine Krawatte um den Kopf gebunden.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 11,
"month": 5,
"title": "Tourbus statt T3",
"note": "Ein Roadie fotografiert die Band vor einem riesigen schwarzen Nightliner-Tourbus, daneben zum Vergleich ein Foto des alten roten T3, das Jule hochhält.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 11,
"month": 9,
"title": "Jule wird Mutter",
"note": "Sven fotografiert Jule mit ihrem Neugeborenen im Backstagebereich, Nele und Kai beugen sich darüber, Bühnenlicht im Hintergrund.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 12,
"month": 3,
"title": "USA-Tour",
"note": "Ein Fan fotografiert die Band vor einem Club in Austin, Texas, alle mit Cowboyhüten, Sven mit einem Burrito.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 12,
"month": 7,
"title": "Wembley-Arena, Support",
"note": "Ein Bühnenfotograf fotografiert die Band beim Support-Auftritt in der Wembley-Arena in London, Sven hält das Mikro ins Publikum.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 13,
"month": 1,
"title": "Erschöpfung",
"note": "Kai fotografiert Sven, der in einem Hotelzimmer in Tokio mit Jetlag auf dem Boden liegt, Koffer offen, Vorhänge zu.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "Sven the morning after a long-haul flight, sitting on the edge of a bed yawning and rubbing his eyes, still in yesterday's clothes, suitcase open on the floor, curtains drawn with a strip of daylight and a glimpse of neon signs outside. Photo taken by Kai from the doorway.",
"imageCharacters": [
"sven"
]
},
{
"age": 13,
"month": 6,
"title": "Stadion, erstes Mal",
"note": "Ein Bühnenfotograf fotografiert die Band vom Bühnenrand im Stadion von Gelsenkirchen, ihrer Heimatstadt, 50.000 Menschen, Feuerzeuge und Handylichter, Sven kniet in Lederhose mit tätowiertem Oberkörper, Kai mit Sonnenbrille, Jule mit Bass in die Luft gestreckt.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 13,
"month": 10,
"title": "Neles Unfall",
"note": "Ein Sanitäter fotografiert Nele auf einer Trage hinter der Bühne, das Handgelenk geschient, Jule hält ihre andere Hand.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ"
},
{
"age": 14,
"month": 2,
"title": "Pause",
"note": "Ein Kellner fotografiert die vier in einer Eckkneipe in Gelsenkirchen, alle in Winterjacken, Sven hält ein Bier hoch, die Stimmung ernst, aber einig.",
"area": "Persönliche Entscheidung",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 14,
"month": 9,
"title": "Kai produziert andere",
"note": "Ein Tontechniker fotografiert Kai hinter einem Mischpult mit einer jungen Band im Aufnahmeraum.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 15,
"month": 5,
"title": "Sven allein auf Tour",
"note": "Ein Zuschauer fotografiert Sven allein mit Akustikgitarre auf einer kleinen Kneipenbühne, vielleicht sechzig Leute, ein Schild „Solo“ (unleserlich).",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "Sven alone with an acoustic guitar on a small pub stage, about sixty people at tables in a dim room, one spotlight, a beer on the stool next to him. Photo taken by someone in the audience.",
"imageCharacters": [
"sven"
]
},
{
"age": 15,
"month": 11,
"title": "Jule bei Nele",
"note": "Neles Freundin fotografiert Jule und Nele, die in Neles Wohnzimmer zwischen Kinderspielzeug mit Bass und Übungspad jammen.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 16,
"month": 4,
"title": "Kais Sohn am Schlagzeug",
"note": "Kai fotografiert seinen 4-jährigen Sohn hinter Neles Schlagzeug im alten Proberaum, Nele hält die Sticks.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 17,
"month": 6,
"title": "Die Nachricht",
"note": "Jule fotografiert ihr Handy mit einer Gruppenchat-Nachricht von Sven (unleserlich), im Hintergrund Kai mit hochgezogenen Augenbrauen.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 17,
"month": 8,
"title": "Reunion im Keller",
"note": "Svens Mutter, jetzt 70, fotografiert die vier wieder im Heizungskeller, alle mit grauen Strähnen, Sven mit Vollbart und verblassten Tattoos, Kai mit kurzen grauen Haaren und Lesebrille, Nele mit grauem Kurzhaarschnitt, Jule weint, Sven umarmt Kai.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 17,
"month": 10,
"title": "Comeback-Single",
"note": "Ein Tontechniker fotografiert die Band im Studio, alle vier stoßen mit Kaffeebechern an, auf dem Bildschirm eine Wellenform.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 18,
"month": 3,
"title": "Ausverkaufte Tour in zwei Stunden",
"note": "Ein Manager fotografiert die Band im Büro, Sven hält den Laptop mit „Ausverkauft“ (unleserlich) hoch, Jule mit beiden Händen im Haar.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 18,
"month": 7,
"title": "Stadion, zweites Mal",
"note": "Ein Bühnenfotograf fotografiert die Band im Olympiastadion Berlin, Kais Sohn, 6, mit Gehörschutz an der Bühnenseite, Kai winkt ihm.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Backstage side view of a stadium concert at dusk: Kai on guitar at the edge of the stage waving towards the wings, where a small boy seen from behind wearing large red ear protectors stands next to a roadie; Sven, Jule and Nele playing further back, huge crowd in the background.",
"imageCharacters": [
"kai",
"sven",
"jule",
"nele"
],
"imageAnchorAges": {
"nele": 33
}
},
{
"age": 19,
"month": 2,
"title": "Svens Vater stirbt",
"note": "Kai fotografiert Sven auf dem Friedhof in Gelsenkirchen, die Band steht hinter ihm, alle in Schwarz, Nele hält seinen Arm.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ"
},
{
"age": 19,
"month": 9,
"title": "Südamerika",
"note": "Ein Fan fotografiert die Band auf einer Bühne in São Paulo, 40.000 Menschen singen den Refrain, Sven hält das Mikro ins Publikum und weint.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "The four-piece band on a big festival stage in São Paulo at night, an enormous crowd of 40,000 people with raised arms under stage lights, Sven at the front holding the microphone out towards the crowd, visibly moved, eyes glistening. Photo taken from the crowd.",
"imageCharacters": [
"sven",
"kai",
"jule",
"nele"
]
},
{
"age": 20,
"month": 5,
"title": "Dokumentarfilm",
"note": "Ein Kameramann fotografiert die vier auf einem Sofa im alten Proberaum vor einem Filmteam, die Eierkartons hängen noch.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 20,
"month": 11,
"title": "Abschied vom T3",
"note": "Jule fotografiert den restaurierten roten T3 im Foyer eines Rockmuseums, die Band davor, Nele liest die Info-Tafel (unleserlich).",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 21,
"month": 6,
"title": "Kai hört auf zu touren",
"note": "Sven fotografiert Kai, der auf einer Parkbank sitzt und ein Bild seines Sohns zeigt, ruhig, entschieden.",
"area": "Persönliche Entscheidung",
"photo": "Fremdaufnahme",
"rating": "Negativ"
},
{
"age": 21,
"month": 10,
"title": "Kai bleibt im Studio",
"note": "Ein Tontechniker fotografiert Kai hinter dem Mischpult, davor Sven, Jule und Nele mit einer jungen Gitarristin im Aufnahmeraum.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 22,
"month": 7,
"title": "Tour mit Ersatzgitarristin",
"note": "Ein Bühnenfotograf fotografiert die Band mit einer jungen Gitarristin auf einem Festival, Sven zeigt auf sie, das Publikum jubelt.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "The four-piece band on a festival stage in the afternoon with a guest guitarist in her late twenties standing in for Kai, Sven pointing at her with a big smile while the crowd cheers; Kai is not on stage. Photo taken by a stage photographer.",
"imageCharacters": [
"sven",
"jule",
"nele"
]
},
{
"age": 23,
"month": 3,
"title": "Nele wird 40",
"note": "Ein Kellner fotografiert die vier, jetzt alle über 40, an einem Restauranttisch, Nele mit Geburtstagskerze auf dem Tiramisu, Kai zum ersten Mal seit Monaten dabei.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv"
},
{
"age": 24,
"month": 4,
"title": "Grammy-Nominierung",
"note": "Ein Pressefotograf fotografiert die Band auf einer Bühne in Los Angeles in Abendgarderobe, Sven mit Sonnenbrille, Jule verdreht die Augen.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 24,
"month": 9,
"title": "Sven sagt Nein zum Stadion",
"note": "Jule fotografiert Sven, der in einem Café einen Tourplan zusammenfaltet und in die Tasche steckt, entspannt.",
"area": "Persönliche Entscheidung",
"photo": "Fremdaufnahme",
"rating": "Neutral"
},
{
"age": 25,
"month": 5,
"title": "Clubtour",
"note": "Ein Zuschauer fotografiert die Band in einem winzigen Club in Bochum, demselben wie beim Bandcontest 1997, Schweiß von der Decke, dreihundert Leute.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 25,
"month": 11,
"title": "Zurück in der Schulaula",
"note": "Ein Lehrer fotografiert die Band auf der Bühne der Schulaula ihres alten Gymnasiums in Gelsenkirchen, 30 Jahre nach dem ersten Auftritt, Sven mit Tränen, Kai wieder an der Gitarre, vor der Bühne Schüler, Eltern und die Band-Mütter in der ersten Reihe.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
},
{
"age": 30,
"month": 3,
"title": "Dreißig Jahre",
"note": "Svens Mutter fotografiert die vier auf der Treppe zum Heizungskeller, die Tür offen, dahinter das alte Schlagzeug mit Eierkartons, alle grau, alle in Lederjacken, alle lachen.",
"area": "Freundschaft",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv"
}
]
}

View file

@ -0,0 +1,614 @@
{
"person": {
"name": "Nadja",
"email": "nadja@thats-me.app",
"birthYear": 1988
},
"events": [
{
"age": 24,
"month": 3,
"title": "Erster Job in der Agentur",
"note": "Eine Kollegin fotografiert Nadja an ihrem ersten Tag in der Hamburger Eventagentur, noch mit Besucherausweis am Pulli.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Nadja's first day at a Hamburg event agency: she stands in a modern open-plan office holding a visitor badge clipped to her sweater, a bit nervous, colleagues blurred in the background",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 24,
"month": 9,
"title": "Tim auf dem Festival",
"note": "Ein verwackeltes Selfie im Regen vor der Festivalbühne zeigt Nadja und Tim, die sich vor zwei Stunden kennengelernt haben.",
"area": "Liebe",
"photo": "Selfie",
"rating": "Sehr positiv",
"imagePrompt": "A shaky selfie in the rain in front of a festival stage at night, Nadja and Tim squeezed into the frame, both soaked, laughing, festival wristbands, stage lights blurred behind them – they met two hours ago",
"imageAspect": "3:4",
"imageCharacters": [
"nadja",
"tim"
]
},
{
"age": 25,
"month": 2,
"title": "Zusammen in Ottensen",
"note": "Ein Selfie zwischen Umzugskartons in der kleinen Altbauwohnung in Ottensen, Tim schleppt im Hintergrund das Sofa.",
"area": "Wohnen",
"photo": "Selfie",
"rating": "Positiv",
"imagePrompt": "Selfie between moving boxes in a small old-building apartment in Hamburg-Ottensen, Nadja in front, Tim in the background carrying one end of a sofa",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"tim"
]
},
{
"age": 25,
"month": 7,
"title": "Erstes eigenes Großevent",
"note": "Ein Kollege fotografiert Nadja mit Headset und Klemmbrett vor dem Eingang der Firmengala, die sie zum ersten Mal allein verantwortet.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Nadja with a headset and a clipboard in front of the entrance of a corporate gala evening, in a black blazer, guests arriving blurred behind her, string lights",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 26,
"month": 5,
"title": "Projektleitung",
"note": "Ein Teamfoto nach der Bekanntgabe, dass Nadja die Projektleitung für den größten Kunden übernimmt.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Team photo in an agency office after an announcement: Nadja in the middle, colleagues around her clapping, a whiteboard behind them, everyone smiling",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 26,
"month": 12,
"title": "Weihnachten mit beiden Familien",
"note": "Nadjas Vater fotografiert die erste gemeinsame Weihnachtstafel beider Familien, Tim und Nadja in der Mitte.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "A long Christmas dinner table in a German family living room, two families together, Nadja and Tim sitting in the middle, candles, wine, a Christmas tree",
"imageAspect": "16:9",
"imageCharacters": [
"nadja",
"tim"
]
},
{
"age": 27,
"month": 6,
"title": "Antrag am Elbstrand",
"note": "Eine Spaziergängerin fotografiert den Moment, in dem Tim am Elbstrand vor Nadja kniet, im Hintergrund fährt ein Containerschiff vorbei.",
"area": "Liebe",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "At the Elbe river beach in Hamburg at sunset, Tim kneeling in the sand in front of Nadja holding a small ring box, Nadja with both hands over her mouth, a huge container ship passing in the background",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"tim"
]
},
{
"age": 27,
"month": 10,
"title": "Krankgeschrieben",
"note": "Ein Selfie auf dem Sofa mit Krankschreibung in der Hand zeigt Nadja nach dem Zusammenbruch auf der Messe – der Arzt sagt Erschöpfung, sie sagt Stress.",
"area": "Persönliche Entwicklung",
"photo": "Selfie",
"rating": "Negativ",
"imagePrompt": "Selfie on a sofa under a blanket, Nadja looking exhausted and pale, holding a folded sheet of paper (a sick note, shown blank), tea cup on the table",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 28,
"month": 5,
"title": "Hochzeit in Dänemark",
"note": "Der Hochzeitsfotograf hält Nadja und Tim barfuß am Strand von Rømø fest, die Gäste werfen Blüten.",
"area": "Hochzeit",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Wedding on the beach of Rømø in Denmark: Nadja in a simple white dress and Tim in a light suit, both barefoot in the sand, guests throwing flower petals, wind, bright sky",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"tim"
]
},
{
"age": 28,
"month": 8,
"title": "Flitterwochen Island",
"note": "Ein Selfie vor einem Wasserfall, beide in tropfnassen Regenjacken und mit breitem Grinsen.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Sehr positiv",
"imagePrompt": "Selfie in front of a huge waterfall in Iceland, Nadja and Tim in dripping wet rain jackets, hoods up, grinning widely",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"tim"
]
},
{
"age": 29,
"month": 3,
"title": "Eigene Agentur mit Tim",
"note": "Ein Freund fotografiert Nadja und Tim vor dem noch leeren Büro in der Schanze, an der Tür klebt ein ausgedrucktes Logo.",
"area": "Selbstständigkeit",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Nadja and Tim standing in front of an empty office space in Hamburg's Schanze quarter, a plain printed logo taped to the glass door (shown blank), both proud, arms around each other",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"tim"
]
},
{
"age": 29,
"month": 11,
"title": "Erster großer Kunde",
"note": "Ein Selfie mit dem unterschriebenen Jahresvertrag eines Getränkeherstellers, Tim küsst Nadja auf die Wange.",
"area": "Selbstständigkeit",
"photo": "Selfie",
"rating": "Sehr positiv",
"imagePrompt": "Selfie in the agency office: Nadja holding up a signed contract (paper shown blank), Tim kissing her on the cheek, laptops and coffee cups on the desk",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"tim"
]
},
{
"age": 30,
"month": 1,
"title": "30. Geburtstag",
"note": "Ein Foto von der Geburtstagsparty im eigenen Büro zeigt Nadja auf dem Schreibtisch tanzend, umringt von Freunden und Team.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "30th birthday party in the agency office at night: Nadja dancing on top of a desk with a glass of sparkling wine, Kim and Tim and friends around her cheering, fairy lights",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"tim",
"kim"
]
},
{
"age": 30,
"month": 6,
"title": "Kinderwunsch-Gespräch",
"note": "Ein Selfie vom Balkon nach einem langen Gespräch über Kinder – Nadja will, Tim sagt: nach dem nächsten Geschäftsjahr.",
"area": "Partnerschaft",
"photo": "Selfie",
"rating": "Neutral",
"imagePrompt": "Selfie on a small balcony in the evening, Nadja alone with a serious, thoughtful look, city rooftops behind her, blue hour; the phone itself is not visible, only her face and one shoulder, arm reaching out of frame",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 30,
"month": 12,
"title": "Acht Mitarbeiter",
"note": "Das Teamfoto zur Weihnachtsfeier zeigt die Agentur mit inzwischen acht Leuten vor dem Bürofenster.",
"area": "Selbstständigkeit",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Christmas party team photo of a small agency, eight people in front of an office window, Nadja and Tim in the middle, paper hats, ugly Christmas sweaters",
"imageAspect": "16:9",
"imageCharacters": [
"nadja",
"tim"
]
},
{
"age": 31,
"month": 4,
"title": "Schwanger",
"note": "Ein Selfie im Badezimmer mit dem positiven Test, Nadja hält sich die Hand vor den Mund.",
"area": "Familie",
"photo": "Selfie",
"rating": "Sehr positiv",
"imagePrompt": "Smartphone selfie of Nadja in a bright tiled bathroom: she has exactly two arms, one reaching out of the frame towards the camera, the other hand pressed over her mouth in surprise, her eyes wide. The positive pregnancy test lies clearly visible on the edge of the washbasin in the foreground. No third arm or hand anywhere in the picture.",
"imageAspect": "3:4",
"imageCharacters": [
"nadja"
]
},
{
"age": 31,
"month": 7,
"title": "Fehlgeburt",
"note": "Ein Selfie im Krankenhausbett in der zwölften Woche, Nadja schaut aus dem Fenster, Tim ist nicht auf dem Bild.",
"area": "Familie",
"photo": "Selfie",
"rating": "Sehr negativ",
"imagePrompt": "Nadja lying in a hospital bed in a gown, turned towards the window, looking outside, pale, an empty chair next to the bed, no one else in the room, muted light",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 31,
"month": 9,
"title": "Tim zieht sich zurück",
"note": "Ein Selfie vom Abendessen zeigt Nadja allein am gedeckten Tisch für zwei, Tim ist wieder länger im Büro.",
"area": "Partnerschaft",
"photo": "Selfie",
"rating": "Negativ",
"imagePrompt": "Selfie at a dinner table set for two with candles, Nadja alone, the second plate untouched, forced small smile, dim kitchen light",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 31,
"month": 12,
"title": "Streit an Weihnachten",
"note": "Ein Familienfoto bei Nadjas Eltern, auf dem Tim und Nadja an entgegengesetzten Enden des Tisches sitzen.",
"area": "Partnerschaft",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "Christmas dinner at Nadja's parents' house: a long table, Nadja sitting at one end and Tim at the opposite end, both looking away from each other, relatives in between",
"imageAspect": "16:9",
"imageCharacters": [
"nadja",
"tim"
]
},
{
"age": 32,
"month": 3,
"title": "Alle Events abgesagt",
"note": "Ein Selfie vor der Wandtafel im Büro, auf der Nadja jeden Termin des Frühjahrs durchgestrichen hat – Lockdown.",
"area": "Selbstständigkeit",
"photo": "Selfie",
"rating": "Sehr negativ",
"imagePrompt": "Nadja in front of a large wall planner in an empty office, every appointment of the spring crossed out with thick marker (writing not legible), arms hanging, March 2020 lockdown",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 32,
"month": 5,
"title": "Tims Affäre",
"note": "Ein Selfie im Auto vor dem Büro, verweint, nachdem Nadja die Nachrichten zwischen Tim und einer Mitarbeiterin gelesen hat.",
"area": "Partnerschaft",
"photo": "Selfie",
"rating": "Sehr negativ",
"imagePrompt": "Selfie in a parked car in front of an office building, Nadja crying, red eyes, smeared mascara, phone in her other hand",
"imageAspect": "3:4",
"imageCharacters": [
"nadja"
]
},
{
"age": 32,
"month": 6,
"title": "Auf Kims Sofa",
"note": "Kim fotografiert Nadja mit einer Reisetasche und einem Kissen auf ihrem Sofa – die erste Nacht nach dem Auszug.",
"area": "Wohnen",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ",
"imagePrompt": "Nadja sitting on Kim's sofa with a travel bag and a pillow, first night after moving out, Kim's arm around her shoulder, both in sweaters, a mug of tea, evening lamp light",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"kim"
]
},
{
"age": 32,
"month": 8,
"title": "Insolvenzantrag",
"note": "Ein Selfie vor dem Amtsgericht mit dem Umschlag des Insolvenzantrags, Nadja schaut an der Kamera vorbei.",
"area": "Selbstständigkeit",
"photo": "Selfie",
"rating": "Sehr negativ",
"imagePrompt": "Selfie in front of a grey German court building (Amtsgericht), Nadja holding a thick brown envelope, not smiling, eyes looking off to the side past the camera, jaw tense, overcast sky",
"imageAspect": "3:4",
"imageCharacters": [
"nadja"
]
},
{
"age": 32,
"month": 10,
"title": "Diagnose Depression",
"note": "Ein Selfie im Wartezimmer der Psychotherapeutin, das Nadja Kim schickt, mit der Nachricht: bin da.",
"area": "Persönliche Entwicklung",
"photo": "Selfie",
"rating": "Negativ",
"imagePrompt": "Selfie in a quiet waiting room of a psychotherapy practice, Nadja in a coat, tired but calm, plants and a water dispenser",
"imageAspect": "3:4",
"imageCharacters": [
"nadja"
]
},
{
"age": 32,
"month": 12,
"title": "Weihnachten allein",
"note": "Ein Selfie beim Spaziergang an der Elbe am Heiligabend, allein, mit Thermoskanne und Mütze.",
"area": "Familie",
"photo": "Selfie",
"rating": "Negativ",
"imagePrompt": "Selfie on a walk along the Elbe river on Christmas Eve, Nadja alone in a wool hat and scarf holding a thermos flask, grey water, harbour cranes far away, cold light",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 33,
"month": 2,
"title": "Scheidung eingereicht",
"note": "Ein Selfie mit dem Scheidungsantrag auf dem Küchentisch bei Kim, daneben eine halb leere Tasse Tee.",
"area": "Partnerschaft",
"photo": "Selfie",
"rating": "Negativ",
"imagePrompt": "Selfie at Kim's kitchen table, Nadja with a stack of official papers in front of her (shown blank), a half-empty cup of tea, neutral, sober expression",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 33,
"month": 4,
"title": "Erste Wanderung",
"note": "Ein Selfie in der Lüneburger Heide, außer Atem, das erste Mal seit Monaten draußen für länger als eine Stunde.",
"area": "Freizeit",
"photo": "Selfie",
"rating": "Neutral",
"imagePrompt": "Selfie on a hike in the Lüneburg Heath, Nadja out of breath, red cheeks, heather and pine trees, first time outside for longer than an hour in months",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 33,
"month": 6,
"title": "Aushilfe in der Gärtnerei",
"note": "Die Chefin der Gärtnerei fotografiert Nadja mit Erde bis zu den Ellenbogen zwischen Tomatensetzlingen.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "Nadja working in a plant nursery, soil up to her elbows, kneeling between tomato seedlings in a greenhouse, hair tied up with a pencil, apron",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 33,
"month": 9,
"title": "Scheidung rechtskräftig",
"note": "Ein Selfie vor dem Gericht, nüchtern, mit dem Beschluss in der Hand. Nicht traurig, nicht froh.",
"area": "Partnerschaft",
"photo": "Selfie",
"rating": "Neutral",
"imagePrompt": "Selfie in front of a court building, Nadja holding a document (shown blank), neutral face, neither sad nor happy, autumn",
"imageAspect": "3:4",
"imageCharacters": [
"nadja"
]
},
{
"age": 33,
"month": 11,
"title": "Holzwerkstatt-Kurs",
"note": "Der Kursleiter fotografiert Nadja mit Schutzbrille an der Bandsäge, zum ersten Mal seit langem konzentriert und ruhig.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Nadja at a band saw in an evening woodworking course, safety goggles on, focused and calm, wood shavings, workshop light",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 34,
"month": 1,
"title": "Bewerbung Tischlerlehre",
"note": "Ein Selfie mit dem ausgedruckten Bewerbungsschreiben, Nadja mit 34 als Azubi – Kim findet es großartig, ihre Mutter nicht.",
"area": "Persönliche Entscheidung",
"photo": "Selfie",
"rating": "Neutral",
"imagePrompt": "Selfie at a kitchen table with a printed application letter (shown blank), Nadja with a determined look, a laptop beside her",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 34,
"month": 8,
"title": "Ausbildungsbeginn",
"note": "Der Meister fotografiert die neuen Azubis der Tischlerei in Lüneburg, Nadja ist mit Abstand die Älteste und die Einzige, die lacht.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Group photo of new apprentices in a carpentry workshop in Lüneburg: four teenagers in new work clothes and Nadja, clearly the oldest and the only one laughing, the master craftsman taking the photo from the front, workbenches behind",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 34,
"month": 10,
"title": "Kleine Wohnung in Lüneburg",
"note": "Ein Selfie in der leeren Ein-Zimmer-Wohnung, eine Matratze, eine Pflanze, eine Werkzeugkiste.",
"area": "Wohnen",
"photo": "Selfie",
"rating": "Positiv",
"imagePrompt": "Selfie in an empty one-room apartment: a mattress on the floor, one plant, one toolbox, Nadja sitting on the mattress smiling",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 35,
"month": 3,
"title": "Erstes eigenes Möbelstück",
"note": "Ein Kollege fotografiert Nadja neben ihrem ersten selbst gebauten Hocker aus Eiche, sichtbar stolz.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Nadja in a carpentry workshop next to her first self-built oak stool, hand resting on it, proud, sawdust on her flannel shirt",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 35,
"month": 7,
"title": "Ben am See",
"note": "Ein Selfie am Badesteg, nass und lachend, mit Ben, den sie beim Schwimmen kennengelernt hat, weil er ihr Handtuch geklaut hat.",
"area": "Liebe",
"photo": "Selfie",
"rating": "Positiv",
"imagePrompt": "Selfie on a wooden bathing jetty at a lake, Nadja and Ben both wet-haired and laughing, towels, summer, sparkling water behind them",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"ben"
]
},
{
"age": 35,
"month": 12,
"title": "Therapie abgeschlossen",
"note": "Ein Selfie vor der Praxistür nach der letzten Sitzung, das Nadja an Kim schickt: fertig.",
"area": "Persönliche Entwicklung",
"photo": "Selfie",
"rating": "Positiv",
"imagePrompt": "Selfie in front of a practice door with a small brass sign (unreadable), Nadja smiling with relief, winter coat, last therapy session",
"imageAspect": "3:4",
"imageCharacters": [
"nadja"
]
},
{
"age": 36,
"month": 6,
"title": "Gesellenprüfung bestanden",
"note": "Der Meister fotografiert Nadja mit dem Gesellenbrief und dem Prüfungsstück, einem Sekretär aus Nussbaum.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Nadja holding her journeyman's certificate (shown blank) next to her exam piece, an elegant walnut writing desk, in a carpentry workshop, master craftsman's hands clapping at the edge of the frame",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 36,
"month": 9,
"title": "Ben zieht ein",
"note": "Ein Selfie vor der neuen Zwei-Zimmer-Wohnung mit Ben, zwei Fahrrädern und einer Werkbank im Flur.",
"area": "Partnerschaft",
"photo": "Selfie",
"rating": "Positiv",
"imagePrompt": "Selfie in front of a new two-room apartment door with Ben, two bicycles and a workbench in the hallway behind them",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"ben"
]
},
{
"age": 36,
"month": 11,
"title": "Tim in Hamburg getroffen",
"note": "Ein Selfie am Hamburger Hauptbahnhof nach einer zufälligen Begegnung mit Tim – zehn Minuten Small Talk, kein Zittern.",
"area": "Persönliche Entwicklung",
"photo": "Selfie",
"rating": "Neutral",
"imagePrompt": "Selfie in the busy main hall of Hamburg central station, Nadja calm and composed, trains and crowds blurred behind her",
"imageAspect": "3:4",
"imageCharacters": [
"nadja"
]
},
{
"age": 37,
"month": 4,
"title": "Eigene Werkstatt",
"note": "Ben fotografiert Nadja vor dem Rolltor ihrer eigenen kleinen Werkstatt am Stadtrand von Lüneburg, das Schild hat sie selbst gefräst.",
"area": "Selbstständigkeit",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Nadja in front of the open roller door of her own small carpentry workshop on the edge of Lüneburg, a hand-milled wooden sign above the door (no readable letters), arms crossed, proud, spring light",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 37,
"month": 8,
"title": "Auftrag aus Hamburg",
"note": "Ein Selfie mit einer E-Mail auf dem Handy: eine ehemalige Agenturkundin bestellt einen Konferenztisch.",
"area": "Beruf",
"photo": "Selfie",
"rating": "Positiv",
"imagePrompt": "Selfie in the workshop holding up her phone screen (blurred), Nadja grinning, a large half-built conference table behind her",
"imageAspect": "4:3",
"imageCharacters": [
"nadja"
]
},
{
"age": 37,
"month": 12,
"title": "Jahresrückblick an der Werkbank",
"note": "Ben fotografiert Nadja an der Werkbank, Sägemehl im Haar, hinter ihr Kim und ein Glas Sekt – fünf Jahre nach dem schlimmsten Jahr.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Nadja at her workbench with sawdust in her hair, Ben and Kim behind her raising glasses of sparkling wine, workshop decorated with a small string of lights, warm evening",
"imageAspect": "4:3",
"imageCharacters": [
"nadja",
"ben",
"kim"
]
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,617 @@
{
"person": {
"name": "Werner",
"email": "werner@thats-me.app",
"birthYear": 1955
},
"events": [
{
"age": 55,
"month": 4,
"title": "35 Jahre im Werk",
"note": "Der Werksfotograf hält die Ehrung für 35 Jahre Betriebszugehörigkeit fest, Werner mit Urkunde und Blumenstrauß zwischen Betriebsleiter und Betriebsrat.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Ceremony in a steel plant canteen: Werner in a checked shirt holding a framed certificate (blank) and a bouquet of flowers, flanked by two men in suits shaking his hand, plain painted wall behind them without any banner or signs, fluorescent light",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
},
{
"age": 55,
"month": 8,
"title": "Enkelin Lina",
"note": "Tochter Anke fotografiert Werner im Krankenhaus, der die neugeborene Lina hält, als wäre sie aus Glas.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "In a hospital room, Werner sitting on a chair holding his newborn granddaughter Lina extremely carefully with both arms as if she were made of glass, Anke in the bed behind him smiling tiredly",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"anke",
"lina"
]
},
{
"age": 56,
"month": 10,
"title": "Stent",
"note": "Renate fotografiert Werner im Krankenhausbett nach dem Herzkatheter, Daumen hoch, aber blass.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "Werner in a hospital bed in a gown after a heart procedure, pale, giving a thumbs up to the camera, a monitor cable on his chest, a bedside table with a water jug",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
},
{
"age": 57,
"month": 5,
"title": "Reha und Rauchstopp",
"note": "Ein Selfie im Kurpark von Bad Sassendorf zeigt Werner mit Nordic-Walking-Stöcken und ohne Zigarette – seit sechs Wochen.",
"area": "Persönliche Entwicklung",
"photo": "Selfie",
"rating": "Neutral",
"imagePrompt": "Selfie in a spa park with autumn-coloured trees, Werner holding two Nordic walking poles, in a sports jacket and flat cap, slightly awkward with the selfie, no cigarette",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
},
{
"age": 57,
"month": 9,
"title": "Jürgen geht nach Kanada",
"note": "Ein Foto am Flughafen Düsseldorf zeigt Werner und Renate mit Sohn Jürgen vor der Abflugtafel; Werner schaut auf die Koffer.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "At Düsseldorf airport in front of a departure board (blurred, unreadable), Werner and Renate standing with their son Jürgen who has a big backpack and two suitcases; Werner looking down at the suitcases, Renate hugging Jürgen",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"renate",
"juergen"
]
},
{
"age": 58,
"month": 7,
"title": "Erste Wohnmobil-Fahrt",
"note": "Ein Selfie vor dem gebrauchten Wohnmobil an der dänischen Nordsee, Renate winkt aus dem Fenster.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Positiv",
"imagePrompt": "Selfie in front of an old white motorhome parked in the dunes at the Danish North Sea coast, Werner in front, Renate waving from the side window, grey sky, grass dunes",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"renate"
]
},
{
"age": 59,
"month": 3,
"title": "Werk wird geschlossen",
"note": "Ein Pressefoto von der Betriebsversammlung zeigt Werner in der ersten Reihe, als die Schließung des Werks verkündet wird.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ",
"imagePrompt": "A works meeting in a large industrial hall: rows of workers in overalls sitting on folding chairs, Werner in the front row with crossed arms and a grim face, a man at a lectern on a small stage, harsh hall lighting",
"imageAspect": "16:9",
"imageCharacters": [
"werner"
]
},
{
"age": 59,
"month": 11,
"title": "Sozialplan verhandelt",
"note": "Ein Kollege fotografiert Werner als Betriebsrat nach der Unterzeichnung des Sozialplans, müde, aber zufrieden.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Werner at a conference table in a plain office after signing a thick document (blank), pen in hand, tired but satisfied, two folders, a jug of water, a colleague's hand patting his shoulder",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
},
{
"age": 60,
"month": 2,
"title": "60. Geburtstag in der Kneipe",
"note": "Der Wirt fotografiert die Runde in der Stammkneipe: Werner, Renate, Anke mit Familie und ein Dutzend alte Kollegen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "A crowded corner pub in Dortmund: Werner at the head of a long wooden table with a beer, Renate beside him, Anke with little Lina on her lap, a dozen older men in shirts raising glasses, wood panelling, warm light",
"imageAspect": "16:9",
"imageCharacters": [
"werner",
"renate",
"anke",
"lina"
]
},
{
"age": 60,
"month": 6,
"title": "Letzte Schicht",
"note": "Ein Kollege fotografiert Werner am letzten Schichttag vor dem Werkstor, den Spind-Schlüssel in der Hand.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "Werner standing in front of a large closed steel factory gate on his last working day, holding a small locker key up to the camera, in work clothes with a bag over his shoulder, overcast light",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
},
{
"age": 60,
"month": 9,
"title": "Ehrenamt bei der Tafel",
"note": "Eine Mitarbeiterin fotografiert Werner beim Sortieren von Gemüsekisten an seinem ersten Tag bei der Tafel.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "Werner in a food bank warehouse sorting crates of vegetables, wearing a plain dark blue apron without any print or logo, reaching into a crate of carrots, shelves of boxes behind him",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
},
{
"age": 61,
"month": 5,
"title": "Enkel Max",
"note": "Anke fotografiert Werner mit dem neugeborenen Max auf dem Arm und Lina auf dem Schoß.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "On a sofa in a living room, Werner holding a newborn baby boy in his arms and six-year-old Lina sitting on his lap leaning in to look at her baby brother, Werner beaming",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"lina"
]
},
{
"age": 61,
"month": 8,
"title": "Zu Besuch in Kanada",
"note": "Jürgen fotografiert seine Eltern vor den Rocky Mountains, Werner zum ersten Mal in seinem Leben außerhalb Europas.",
"area": "Reisen",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Werner and Renate standing in front of the Rocky Mountains in Canada, snow-capped peaks and a turquoise lake behind them, Werner in a flat cap looking stunned and happy, Jürgen's arm around his father",
"imageAspect": "16:9",
"imageCharacters": [
"werner",
"renate",
"juergen"
]
},
{
"age": 62,
"month": 4,
"title": "Diagnose Brustkrebs",
"note": "Ein Selfie von Renate im Wartezimmer, das sie Werner schickt, während er draußen im Auto wartet, weil er nicht mit hinein darf.",
"area": "Familie",
"photo": "Selfie",
"rating": "Sehr negativ",
"imagePrompt": "Selfie of Renate alone in a hospital waiting room, an anxious, brave little smile, tired eyes, sitting on a plastic chair, the phone itself not visible, only her face and shoulders, a blurred poster on the wall, fluorescent light",
"imageAspect": "3:4",
"imageCharacters": [
"renate"
]
},
{
"age": 62,
"month": 10,
"title": "Chemo",
"note": "Renate fotografiert Werner in der Küche, der zum ersten Mal in seinem Leben nach Rezept kocht – Hühnersuppe.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "Werner in a small kitchen cooking chicken soup for the first time in his life, reading glasses on, a cookbook open on the counter (pages blank), a big pot steaming, looking concentrated and slightly overwhelmed",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
},
{
"age": 63,
"month": 3,
"title": "Krebsfrei",
"note": "Ein Selfie von Renate und Werner vor der Klinik nach dem Befund, beide weinen, beide lachen.",
"area": "Familie",
"photo": "Selfie",
"rating": "Sehr positiv",
"imagePrompt": "Selfie of Renate and Werner in front of a hospital entrance, Renate with a knitted hat over very short hair, both crying and laughing at the same time, foreheads touching",
"imageAspect": "3:4",
"imageCharacters": [
"werner",
"renate"
]
},
{
"age": 63,
"month": 7,
"title": "Rente",
"note": "Anke fotografiert Werner mit dem Rentenbescheid am Küchentisch, Renate stößt mit Sekt an.",
"area": "Beruf",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "At a kitchen table, Werner holding an official letter (blank) with a big grin, Renate next to him in a knitted hat clinking a glass of sparkling wine against his, a cake on the table",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"renate"
]
},
{
"age": 63,
"month": 9,
"title": "Norwegen",
"note": "Ein Selfie am Geirangerfjord vor dem Wohnmobil, Renate mit kurzem Haar und Mütze, Werner mit Bart.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Sehr positiv",
"imagePrompt": "Selfie in front of a motorhome at a viewpoint over a Norwegian fjord, steep green mountains and dark water, Renate with short grey hair and a hat, Werner with a short grey beard, both in windbreakers",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"renate"
]
},
{
"age": 64,
"month": 5,
"title": "Laube am Kanal",
"note": "Ein Nachbar fotografiert Werner und Renate vor ihrer neu gepachteten Gartenlaube am Kanal, Werner mit Spaten.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Werner and Renate standing in front of a small wooden allotment cabin by a canal, Werner leaning on a spade, Renate with a watering can, flower beds freshly dug, spring light",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"renate"
]
},
{
"age": 64,
"month": 12,
"title": "Renate ist müde",
"note": "Ein Selfie am Weihnachtsabend, Renate lehnt an Werners Schulter und schläft, während die Enkel spielen.",
"area": "Familie",
"photo": "Selfie",
"rating": "Negativ",
"imagePrompt": "Selfie on Christmas Eve on a sofa, Renate asleep leaning on Werner's shoulder, Werner looking at the camera with a soft, worried look, Christmas tree lights blurred, grandchildren's toys on the floor",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"renate"
]
},
{
"age": 65,
"month": 2,
"title": "Rückfall",
"note": "Ein Selfie von Werner allein im Klinikparkhaus, nachdem der Arzt von Metastasen gesprochen hat.",
"area": "Familie",
"photo": "Selfie",
"rating": "Sehr negativ",
"imagePrompt": "Selfie of Werner alone in a dim concrete hospital car park, leaning against his car, face grey and empty, winter coat, cold neon light",
"imageAspect": "3:4",
"imageCharacters": [
"werner"
]
},
{
"age": 65,
"month": 4,
"title": "Krankenhaus ohne Besuch",
"note": "Ein Selfie von Werner unter Renates Fenster im dritten Stock, Lockdown, sie winkt von oben.",
"area": "Familie",
"photo": "Selfie",
"rating": "Sehr negativ",
"imagePrompt": "Selfie of Werner standing on a lawn below a hospital building, looking up towards a third-floor window where a small figure in a hat waves, spring, everything empty because of lockdown, mask hanging under his chin",
"imageAspect": "3:4",
"imageCharacters": [
"werner"
]
},
{
"age": 65,
"month": 7,
"title": "Hospiz",
"note": "Eine Pflegerin fotografiert Werner und Renate Hand in Hand auf der Terrasse des Hospizes, Renate im Rollstuhl mit Decke.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ",
"imagePrompt": "On the terrace of a hospice, Werner and Renate holding hands, Renate very thin in a wheelchair wrapped in a blanket and a knitted hat, Werner on a chair beside her leaning close, summer garden, soft light",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"renate"
]
},
{
"age": 65,
"month": 8,
"title": "Abschied von Renate",
"note": "Anke fotografiert das leere Bett im Hospiz mit Renates Brille auf dem Nachttisch – Werner wollte dieses Bild haben.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ",
"imagePrompt": "An empty, neatly made hospice bed by a window, Renate's reading glasses and a small vase with a single flower on the bedside table, morning light, no people",
"imageAspect": "4:3",
"imageCharacters": []
},
{
"age": 65,
"month": 9,
"title": "Beerdigung",
"note": "Ein Foto vom Friedhof zeigt Werner, Anke und die Enkel, Jürgen ist auf dem Tablet zugeschaltet, das Lina hochhält.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr negativ",
"imagePrompt": "At a cemetery after a funeral: Werner in a black suit standing between Anke and eleven-year-old Lina, Lina holding up a tablet on which a bearded man is visible on a video call (screen blurred), grey day, wreaths",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"anke",
"lina"
]
},
{
"age": 65,
"month": 12,
"title": "Erstes Weihnachten allein",
"note": "Anke fotografiert Werner an ihrem Weihnachtstisch, den Blick auf den Stuhl, den sie für Renate frei gelassen hat.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "Christmas dinner at Anke's house: Werner sitting at the table looking at an empty chair with a folded napkin, Anke standing behind him with a hand on his shoulder, candles, a small tree",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"anke"
]
},
{
"age": 66,
"month": 3,
"title": "Renates Sachen",
"note": "Ein Selfie im Schlafzimmer vor dem offenen Kleiderschrank, Werner hält einen Mantel von Renate, nichts davon kommt weg.",
"area": "Wohnen",
"photo": "Selfie",
"rating": "Negativ",
"imagePrompt": "Selfie of Werner in a bedroom in front of an open wardrobe full of women's clothes, holding a beige wool coat in both hands, eyes red, nothing has been given away",
"imageAspect": "3:4",
"imageCharacters": [
"werner"
]
},
{
"age": 66,
"month": 6,
"title": "Lina erklärt das Smartphone",
"note": "Anke fotografiert Lina und Werner auf dem Sofa, Lina zeigt ihm, wie man Fotos verschickt; er hat einen Zettel mit Notizen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "On a sofa, eleven-year-old Lina showing her grandfather Werner how to use a smartphone, Werner with reading glasses holding a handwritten note (unreadable), both concentrating, afternoon light",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"lina"
]
},
{
"age": 66,
"month": 10,
"title": "Trauergruppe",
"note": "Ein Gruppenfoto der Trauergruppe im Gemeindehaus, Werner am Rand, der Einzige mit Krawatte.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "Group photo of a bereavement group in a church community hall: eight elderly people on chairs in a circle, Werner at the edge, the only one wearing a tie, a coffee thermos on a side table",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
},
{
"age": 67,
"month": 4,
"title": "Kassenwart im Kleingarten",
"note": "Der Vereinsvorsitzende fotografiert Werner bei seiner Wahl zum Kassenwart, Kassenbuch unterm Arm.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "Werner being congratulated at an allotment club meeting, holding a thick ledger under his arm, a handshake from an elderly man in a club vest, a small clubhouse with trophies",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
},
{
"age": 67,
"month": 7,
"title": "Allein nach Dänemark",
"note": "Ein Selfie am selben Strand wie vor neun Jahren, aber der Beifahrersitz im Wohnmobil ist leer.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Negativ",
"imagePrompt": "Selfie of Werner alone on a Danish North Sea beach in front of the old motorhome, the passenger seat visible through the open door and empty, grey sky, a sad half-smile",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
},
{
"age": 67,
"month": 11,
"title": "Wanderverein",
"note": "Ein Gruppenfoto der Mittwochswanderer im Sauerland, Werner hinten links, zum ersten Mal wieder lachend auf einem Foto.",
"area": "Freizeit",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "Group photo of a hiking club in the Sauerland hills in autumn, twelve elderly hikers with poles and backpacks in front of a wooden signpost (unreadable), Werner at the back left laughing for the first time in a long while",
"imageAspect": "16:9",
"imageCharacters": [
"werner"
]
},
{
"age": 68,
"month": 3,
"title": "Kaffee mit Hannelore",
"note": "Eine Bedienung fotografiert Werner und Hannelore aus dem Wanderverein beim Kaffeetrinken, beide sitzen sehr aufrecht.",
"area": "Liebe",
"photo": "Fremdaufnahme",
"rating": "Positiv",
"imagePrompt": "In a café, Werner and Hannelore at a small table with coffee and cake, both sitting very upright, a bit formal, Hannelore in a scarf, Werner in a cardigan, a waitress's hand at the edge of the frame",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"hannelore"
]
},
{
"age": 68,
"month": 8,
"title": "Jürgen zu Besuch",
"note": "Ein Familienfoto im Garten zeigt Werner mit allen vier Enkeln, Jürgen und seiner kanadischen Frau, zum ersten Mal alle zusammen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Big family photo in a garden: Werner in the middle with four grandchildren around him, Jürgen with a full beard and his wife, Anke, thirteen-year-old Lina, everyone squinting in the summer sun, a barbecue smoking behind",
"imageAspect": "16:9",
"imageCharacters": [
"werner",
"juergen",
"anke",
"lina"
]
},
{
"age": 68,
"month": 10,
"title": "Hannelore bei Anke",
"note": "Anke fotografiert Werner und Hannelore auf ihrem Sofa; Lina schaut skeptisch, Max isst Kuchen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "On Anke's sofa: Werner and Hannelore sitting close together, thirteen-year-old Lina looking at them sceptically from an armchair, a small boy eating cake on the floor, Anke pouring coffee",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"hannelore",
"lina",
"anke"
]
},
{
"age": 69,
"month": 5,
"title": "Bodensee mit Hannelore",
"note": "Ein Selfie vor dem Wohnmobil am Bodensee, Hannelore auf dem Beifahrersitz, Werner mit neuer Sonnenbrille.",
"area": "Reisen",
"photo": "Selfie",
"rating": "Sehr positiv",
"imagePrompt": "Selfie in front of the motorhome at Lake Constance, Hannelore in the passenger seat waving, Werner with new sunglasses grinning, blue lake and mountains, bright spring",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"hannelore"
]
},
{
"age": 69,
"month": 9,
"title": "Sturz",
"note": "Hannelore fotografiert Werner im Krankenhaus mit Hüftprothese und Gehwagen, grimmig, aber aufrecht.",
"area": "Persönliche Entwicklung",
"photo": "Fremdaufnahme",
"rating": "Negativ",
"imagePrompt": "Werner in a hospital corridor with a walking frame, grim but upright, a hospital gown and a cardigan, Hannelore's hand on his arm, taken by her",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"hannelore"
]
},
{
"age": 69,
"month": 12,
"title": "Weihnachten mit allen",
"note": "Lina fotografiert die volle Weihnachtstafel: Werner am Kopfende, Hannelore neben ihm, beide Kinder, alle Enkel.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "Long Christmas dinner table: Werner at the head, Hannelore beside him, Anke, Jürgen and his family, all grandchildren, a bit chaotic and warm, candles and a tree, taken by fifteen-year-old Lina from the end of the table",
"imageAspect": "16:9",
"imageCharacters": [
"werner",
"hannelore",
"anke",
"juergen",
"lina"
]
},
{
"age": 70,
"month": 2,
"title": "70. Geburtstag",
"note": "Ein Foto zeigt Lina, inzwischen 14, die eine Rede auf ihren Opa hält, Werner wischt sich mit der Serviette die Augen.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Sehr positiv",
"imagePrompt": "At a 70th birthday party in a restaurant: fifteen-year-old Lina standing giving a speech with a sheet of paper (blank), Werner sitting at the table wiping his eyes with a napkin, relatives around, balloons",
"imageAspect": "4:3",
"imageCharacters": [
"werner",
"lina"
]
},
{
"age": 70,
"month": 6,
"title": "Hannelore zieht ein",
"note": "Ein Selfie im Flur zwischen Hannelores Kartons, Renates Foto hängt weiterhin an der Wand, Hannelore hat es selbst so gewollt.",
"area": "Wohnen",
"photo": "Selfie",
"rating": "Positiv",
"imagePrompt": "Selfie in a hallway between moving boxes, Werner and Hannelore squeezed into the frame, a framed photo of a smiling woman with auburn curls still hanging on the wall behind them",
"imageAspect": "3:4",
"imageCharacters": [
"werner",
"hannelore"
]
},
{
"age": 70,
"month": 11,
"title": "Am Grab mit Hannelore",
"note": "Hannelore fotografiert Werner an Renates Grab mit einem Strauß Astern; sie hat die Blumen ausgesucht.",
"area": "Familie",
"photo": "Fremdaufnahme",
"rating": "Neutral",
"imagePrompt": "Werner standing at a well-kept grave in a cemetery in November, holding a bunch of purple asters, flat cap, coat, fallen leaves on the path, seen from a few metres away, quiet",
"imageAspect": "4:3",
"imageCharacters": [
"werner"
]
}
]
}

View file

@ -18,7 +18,11 @@ class EventFactory extends Factory
'user_id' => User::factory(),
'title' => fake()->sentence(3),
'date' => fake()->date(),
'end_date' => null,
'category' => null,
'people' => null,
'emotion' => fake()->randomFloat(3, -1, 1),
'milestone' => false,
'custom_color' => null,
'gradient_preset' => fake()->optional(0.3)->numberBetween(0, 9),
'image' => null,

View file

@ -26,6 +26,7 @@ class EventMediaFactory extends Factory
'user_id' => $user,
'event_id' => Event::factory()->for($user),
'collection' => 'gallery',
'type' => 'image',
'name' => fake()->word().'.jpg',
'mime_type' => 'image/jpeg',
'disk' => 'local',
@ -33,6 +34,7 @@ class EventMediaFactory extends Factory
'thumbnail_path' => 'event-media/test/thumb.jpg',
'preview_path' => 'event-media/test/preview.jpg',
'size' => 12345,
'duration_ms' => null,
'width' => 1200,
'height' => 800,
'thumbnail_width' => 320,

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
$table->string('location')->nullable()->after('date');
$table->string('key_image_title')->nullable()->after('image');
// The API accepts image URLs up to 500 characters; the column was
// still at the 255 default, so long external URLs failed on MySQL.
$table->string('image', 500)->nullable()->change();
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropColumn(['location', 'key_image_title']);
$table->string('image')->nullable()->change();
});
}
};

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Event media used to be images only. Gallery uploads may now be videos
* and an event can carry one voice note, so a row needs to say what it
* holds, how long it plays, and it may exist without a thumbnail (audio,
* or a video whose poster frame could not be extracted).
*/
public function up(): void
{
Schema::table('event_media', function (Blueprint $table) {
$table->string('type', 16)->default('image')->after('collection');
$table->unsignedInteger('duration_ms')->nullable()->after('size');
$table->string('thumbnail_path')->nullable()->change();
});
}
public function down(): void
{
Schema::table('event_media', function (Blueprint $table) {
$table->dropColumn(['type', 'duration_ms']);
$table->string('thumbnail_path')->nullable(false)->change();
});
}
};

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* A short note next to a media item, so a voice note can be labelled or
* amended in writing instead of being re-recorded.
*/
public function up(): void
{
Schema::table('event_media', function (Blueprint $table) {
$table->string('caption', 500)->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('event_media', function (Blueprint $table) {
$table->dropColumn('caption');
});
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
// Kategorie: a key from the client's fixed list (family, work, ...).
$table->string('category', 40)->nullable()->after('location');
// Personen: free-text names, kept as a JSON list.
$table->json('people')->nullable()->after('category');
// Zeitraum: an event may span until this day; null means one day.
$table->date('end_date')->nullable()->after('date');
// Meilenstein: highlighted on the wave.
$table->boolean('milestone')->default(false)->after('emotion');
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropColumn(['category', 'people', 'end_date', 'milestone']);
});
}
};

View file

@ -34,6 +34,8 @@ class DatabaseSeeder extends Seeder
);
}
$this->call(LifelineSeeder::class);
$hasPersonalAccessClient = Client::query()
->where('provider', 'users')
->where('revoked', false)

View file

@ -0,0 +1,137 @@
<?php
namespace Database\Seeders;
use App\Models\Event;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\File;
use Ramsey\Uuid\Uuid;
use RuntimeException;
/**
* Seeds one complete demo lifeline per person from
* `database/data/lifelines/*.json` (Jonas, Sophie, ...).
*
* Every event gets a deterministic client_id, so re-running the seeder
* updates title/date/emotion/note in place instead of duplicating events.
* `image`, `key_image_title` and attached media are never touched, so the
* key images imported by hand survive a re-seed.
*/
class LifelineSeeder extends Seeder
{
/**
* Namespace for the UUIDv5 client ids of seeded events.
*/
private const string CLIENT_ID_NAMESPACE = '6f1c2a1e-3b7d-4c4a-9d2e-1f0a5b8c9d01';
/**
* Emotion range per rating, aligned with the label thresholds in the
* Quasar EventPanel (> 0.5, > 0.15, > -0.15, > -0.5). Values are spread
* inside each band so the LifeWave gets a lively curve while every
* event still shows the rating from the source table.
*
* @var array<string, array{float, float}>
*/
private const array EMOTION_BANDS = [
'Sehr positiv' => [0.55, 0.95],
'Positiv' => [0.20, 0.48],
'Neutral' => [-0.12, 0.12],
'Negativ' => [-0.48, -0.20],
'Sehr negativ' => [-0.95, -0.55],
];
public function run(): void
{
foreach (self::lifelineFiles() as $path) {
$this->seedLifeline($path);
}
}
/**
* @return array<int, string>
*/
public static function lifelineFiles(): array
{
$files = File::glob(database_path('data/lifelines/*.json'));
sort($files);
return $files;
}
private function seedLifeline(string $path): void
{
$lifeline = json_decode(File::get($path), true, flags: JSON_THROW_ON_ERROR);
$person = $lifeline['person'];
$user = User::query()->updateOrCreate(
['email' => $person['email']],
[
'name' => $person['name'],
'email_verified_at' => now(),
'password' => 'pass',
],
);
foreach ($lifeline['events'] as $event) {
$day = $event['day'] ?? null;
Event::query()->updateOrCreate(
['client_id' => self::clientId($person['email'], $event['age'], $event['month'], $day)],
[
'user_id' => $user->id,
'title' => $event['title'],
'date' => self::date($person['birthYear'], $event['age'], $event['month'], $person['email'], $day),
'emotion' => self::emotion($event['rating'], $person['email'], $event['age'], $event['month'], $day),
'note' => $event['note'],
],
);
}
}
/**
* Events are keyed by age and month; a lifeline with several events in
* one month sets an explicit `day`, which then becomes part of the key.
*/
public static function clientId(string $email, int $age, int $month, ?int $day = null): string
{
return Uuid::uuid5(self::CLIENT_ID_NAMESPACE, self::key($email, $age, $month, $day))->toString();
}
/**
* Without an explicit day, one is drawn deterministically so the same
* event always lands on the same date.
*/
public static function date(int $birthYear, int $age, int $month, string $email, ?int $day = null): string
{
$day ??= 1 + self::fraction('day|'.self::key($email, $age, $month), 28);
return sprintf('%04d-%02d-%02d', $birthYear + $age, $month, $day);
}
public static function emotion(string $rating, string $email, int $age, int $month, ?int $day = null): float
{
$band = self::EMOTION_BANDS[$rating] ?? throw new RuntimeException("Unknown rating [{$rating}]");
[$min, $max] = $band;
$offset = self::fraction('emotion|'.self::key($email, $age, $month, $day), 1000) / 1000;
return round($min + ($max - $min) * $offset, 3);
}
private static function key(string $email, int $age, int $month, ?int $day = null): string
{
return $day === null
? "{$email}|{$age}|{$month}"
: "{$email}|{$age}|{$month}|{$day}";
}
/**
* Stable pseudo-random integer in [0, $modulo) derived from a seed string.
*/
private static function fraction(string $seed, int $modulo): int
{
return crc32($seed) % $modulo;
}
}

View file

@ -1,6 +1,7 @@
<?php
use App\Livewire\Actions\Logout;
use App\Services\MediaCleaner;
use Illuminate\Support\Facades\Auth;
use Livewire\Volt\Component;
@ -10,13 +11,19 @@ new class extends Component {
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
public function deleteUser(Logout $logout, MediaCleaner $mediaCleaner): void
{
$this->validate([
'password' => ['required', 'string', 'current_password'],
]);
tap(Auth::user(), $logout(...))->delete();
$user = Auth::user();
// Rows vanish through the database cascade; the files behind them
// have to be collected and removed before that happens.
$mediaCleaner->purgeUserMedia($user);
tap($user, $logout(...))->delete();
$this->redirect('/', navigate: true);
}

View file

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\ClientLogController;
use App\Http\Controllers\Api\EventController;
use App\Http\Controllers\Api\EventMediaController;
use App\Http\Controllers\Api\SettingsController;
@ -8,19 +9,20 @@ use App\Http\Controllers\Api\SettingsMediaController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']);
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:api');
Route::middleware('auth:api')->group(function () {
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
Route::get('/user', fn (Request $request) => $request->user());
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/settings', [SettingsController::class, 'show']);
Route::put('/settings', [SettingsController::class, 'update']);
Route::get('/settings/media/background', [SettingsMediaController::class, 'show']);
Route::post('/settings/media/background', [SettingsMediaController::class, 'store']);
Route::post('/settings/media/background', [SettingsMediaController::class, 'store'])
->middleware('throttle:uploads');
Route::delete('/settings/media/background', [SettingsMediaController::class, 'destroy']);
Route::get('/event-media/{media}/{variant}', [EventMediaController::class, 'show']);
Route::get('/events/{clientId}/media', [EventMediaController::class, 'index']);
Route::post('/events/{clientId}/media', [EventMediaController::class, 'store']);
Route::post('/events/{clientId}/media', [EventMediaController::class, 'store'])
->middleware('throttle:uploads');
Route::patch('/events/{clientId}/media/{media}', [EventMediaController::class, 'update']);
Route::delete('/events/{clientId}/media/{media}', [EventMediaController::class, 'destroy']);
Route::apiResource('events', EventController::class)->parameters([
@ -28,3 +30,18 @@ Route::middleware('auth:api')->group(function () {
]);
Route::post('/events/sync', [EventController::class, 'sync']);
});
// Browser-side warnings and errors, batched by the app. Own budget so a
// client that is failing loudly cannot lock itself out of the real API.
Route::middleware(['auth:api', 'throttle:client-logs'])->group(function () {
Route::post('/client-logs', [ClientLogController::class, 'store']);
});
// Image reads get their own budget: a long timeline fetches one thumbnail per
// visible event, and that must never eat into the limit for the actual API
// writes (which is what showed up as "Einstellungen konnten nicht gespeichert
// werden" while scrolling).
Route::middleware(['auth:api', 'throttle:media'])->group(function () {
Route::get('/settings/media/background', [SettingsMediaController::class, 'show']);
Route::get('/event-media/{media}/{variant}', [EventMediaController::class, 'show']);
});

View file

@ -82,6 +82,16 @@ test('database seeder creates the presentation api users', function () {
->and(Hash::check('pass', $user->password))->toBeTrue();
}
foreach (['jonas' => 'Jonas', 'sophie' => 'Sophie'] as $handle => $name) {
$user = User::query()
->where('email', "{$handle}@thats-me.app")
->first();
expect($user)->not->toBeNull()
->and($user->name)->toBe($name)
->and(Hash::check('pass', $user->password))->toBeTrue();
}
$hasPersonalAccessClient = Client::query()
->where('provider', 'users')
->where('revoked', false)

View file

@ -0,0 +1,84 @@
<?php
use App\Http\Controllers\Api\ClientLogController;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Laravel\Passport\Passport;
use Psr\Log\LoggerInterface;
test('client logs require a token', function () {
$this->postJson('/api/client-logs', ['entries' => []])
->assertUnauthorized();
});
test('client log entries are written to the client channel with the user attached', function () {
$user = User::factory()->create();
Passport::actingAs($user);
$logger = Mockery::mock(LoggerInterface::class);
Log::shouldReceive('channel')->once()->with(ClientLogController::CHANNEL)->andReturn($logger);
$logger->shouldReceive('log')->once()->withArgs(function (string $level, string $message, array $context) use ($user): bool {
return $level === 'error'
&& $message === '[api] PUT /settings failed'
&& $context['user_id'] === $user->id
&& $context['app_version'] === '0.0.1'
&& $context['url'] === '/#/'
&& $context['context'] === ['status' => 429, 'path' => '/settings'];
});
$logger->shouldReceive('log')->once()->withArgs(function (string $level, string $message, array $context): bool {
return $level === 'warning'
&& $message === '[image] Thumbnail load failed'
&& ! array_key_exists('context', $context);
});
$this->postJson('/api/client-logs', [
'appVersion' => '0.0.1',
'sessionId' => 'abc',
'userAgent' => 'Safari',
'entries' => [
[
'level' => 'error',
'scope' => 'api',
'message' => 'PUT /settings failed',
'url' => '/#/',
'occurredAt' => '2026-09-25T10:00:00.000Z',
'context' => ['status' => 429, 'path' => '/settings'],
],
[
'level' => 'warning',
'scope' => 'image',
'message' => 'Thumbnail load failed',
'occurredAt' => '2026-09-25T10:00:01.000Z',
],
],
])->assertNoContent();
});
test('client logs reject unknown levels, oversized batches and nested context', function () {
Passport::actingAs(User::factory()->create());
$entry = fn (array $overrides = []) => [
'level' => 'error',
'scope' => 'api',
'message' => 'x',
'occurredAt' => '2026-09-25T10:00:00.000Z',
...$overrides,
];
$this->postJson('/api/client-logs', ['entries' => [$entry(['level' => 'debug'])]])
->assertStatus(422)
->assertJsonValidationErrors(['entries.0.level']);
$this->postJson('/api/client-logs', ['entries' => array_fill(0, 51, $entry())])
->assertStatus(422)
->assertJsonValidationErrors(['entries']);
$this->postJson('/api/client-logs', ['entries' => [$entry(['context' => ['nested' => ['a' => 1]]])]])
->assertStatus(422)
->assertJsonValidationErrors(['entries.0.context.nested']);
$this->postJson('/api/client-logs', ['entries' => []])
->assertStatus(422)
->assertJsonValidationErrors(['entries']);
});

View file

@ -0,0 +1,293 @@
<?php
use App\Models\Event;
use App\Models\EventMedia;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Passport\Passport;
beforeEach(function () {
$this->user = User::factory()->create();
Passport::actingAs($this->user);
});
/*
|--------------------------------------------------------------------------
| A05 / A19 — location, keyImageTitle and long image URLs
|--------------------------------------------------------------------------
*/
test('location and key image title survive a create, update and reload roundtrip', function () {
$clientId = Str::uuid()->toString();
$this->postJson('/api/events', [
'id' => $clientId,
'title' => 'Bergwanderung',
'date' => '2014-08-12',
'emotion' => 0.75,
'location' => 'Zugspitze',
'keyImageTitle' => 'Gipfelfoto',
])->assertCreated()
->assertJsonPath('data.location', 'Zugspitze')
->assertJsonPath('data.keyImageTitle', 'Gipfelfoto');
$this->assertDatabaseHas('events', [
'client_id' => $clientId,
'location' => 'Zugspitze',
'key_image_title' => 'Gipfelfoto',
]);
$this->putJson("/api/events/{$clientId}", ['location' => 'Wank'])
->assertOk()
->assertJsonPath('data.location', 'Wank')
->assertJsonPath('data.keyImageTitle', 'Gipfelfoto');
// Reload: the fields must come back, not be blanked out by the response.
$this->getJson("/api/events/{$clientId}")
->assertOk()
->assertJsonPath('data.location', 'Wank')
->assertJsonPath('data.keyImageTitle', 'Gipfelfoto');
});
test('an update without location leaves the stored location untouched', function () {
$event = Event::factory()->create([
'user_id' => $this->user->id,
'location' => 'Zugspitze',
]);
$this->putJson("/api/events/{$event->client_id}", ['title' => 'Neuer Titel'])
->assertOk()
->assertJsonPath('data.location', 'Zugspitze');
});
test('an image url of the documented maximum length is accepted', function () {
// The column was 255 while the rule allowed 500. Asserted here at the
// contract boundary; SQLite does not enforce column width, so the MySQL
// side of this is covered by the widened migration, not by this test.
$url = 'https://example.com/'.str_repeat('a', 500 - 20);
expect(strlen($url))->toBe(500);
$clientId = Str::uuid()->toString();
$this->postJson('/api/events', [
'id' => $clientId,
'title' => 'Langer Link',
'date' => '2025-01-01',
'emotion' => 0,
'image' => $url,
])->assertCreated();
expect(Event::where('client_id', $clientId)->value('image'))->toBe($url);
});
test('an image url beyond the documented maximum is rejected', function () {
$this->postJson('/api/events', [
'id' => Str::uuid()->toString(),
'title' => 'Zu langer Link',
'date' => '2025-01-01',
'emotion' => 0,
'image' => 'https://example.com/'.str_repeat('a', 500),
])->assertStatus(422)->assertJsonValidationErrors('image');
});
/*
|--------------------------------------------------------------------------
| A08 — cursor pagination across equal dates
|--------------------------------------------------------------------------
*/
test('paginating through events sharing one date loses no records', function () {
$sameDay = '2025-05-01';
collect(range(1, 5))->each(fn (int $n) => Event::factory()->create([
'user_id' => $this->user->id,
'date' => $sameDay,
'title' => "Event {$n}",
]));
$seen = [];
$url = '/api/events?limit=1';
// Guard against an endless loop if the cursor ever stops advancing.
for ($page = 0; $page < 20 && $url !== null; $page++) {
$response = $this->getJson($url)->assertOk();
$payload = $response->json();
foreach ($payload['data'] as $event) {
$seen[] = $event['id'];
}
$next = $payload['links']['next'] ?? null;
if ($next === null) {
$url = null;
continue;
}
parse_str((string) parse_url($next, PHP_URL_QUERY), $query);
$url = '/api/events?'.http_build_query($query);
}
expect($seen)->toHaveCount(5)
->and(array_unique($seen))->toHaveCount(5);
});
test('the limit parameter is validated instead of silently coerced', function () {
$this->getJson('/api/events?limit=0')->assertStatus(422);
$this->getJson('/api/events?limit=500')->assertStatus(422);
$this->getJson('/api/events?limit=abc')->assertStatus(422);
$this->getJson('/api/events?since=not-a-date')->assertStatus(422);
});
/*
|--------------------------------------------------------------------------
| A09 — sync shares the CRUD validation rules
|--------------------------------------------------------------------------
*/
test('sync rejects an emotion that plain crud would reject', function () {
$clientId = Str::uuid()->toString();
$response = $this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'create',
'eventId' => $clientId,
'payload' => ['title' => 'Out of range', 'date' => '2025-01-01', 'emotion' => 2],
]],
])->assertOk();
expect($response->json('results.0.status'))->toBe('invalid');
$this->assertDatabaseMissing('events', ['client_id' => $clientId]);
});
test('sync rejects an out of range emotion on update too', function () {
$event = Event::factory()->create([
'user_id' => $this->user->id,
'emotion' => 0.5,
]);
$response = $this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'update',
'eventId' => $event->client_id,
'payload' => ['emotion' => -7],
]],
])->assertOk();
expect($response->json('results.0.status'))->toBe('invalid');
expect((float) $event->fresh()->emotion)->toBe(0.5);
});
test('sync reports a missing event instead of acknowledging it', function () {
$response = $this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'update',
'eventId' => Str::uuid()->toString(),
'payload' => ['title' => 'Ghost'],
]],
])->assertOk();
expect($response->json('results.0.status'))->toBe('not_found');
});
test('sync never returns raw exception text to the client', function () {
$event = Event::factory()->create(['user_id' => $this->user->id]);
$response = $this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'update',
'eventId' => $event->client_id,
'payload' => ['title' => str_repeat('x', 5000)],
]],
])->assertOk();
$result = $response->json('results.0');
expect($result['status'])->toBe('invalid')
->and($result)->not->toHaveKey('message');
});
test('sync writes location and key image title like crud does', function () {
$clientId = Str::uuid()->toString();
$this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'create',
'eventId' => $clientId,
'payload' => [
'title' => 'Via sync',
'date' => '2025-01-01',
'emotion' => 0,
'location' => 'Berlin',
'keyImageTitle' => 'Titelbild',
],
]],
])->assertOk();
$this->assertDatabaseHas('events', [
'client_id' => $clientId,
'location' => 'Berlin',
'key_image_title' => 'Titelbild',
]);
});
/*
|--------------------------------------------------------------------------
| A13 — deleting an event removes its files
|--------------------------------------------------------------------------
*/
test('deleting an event removes its media files', function () {
Storage::fake('local');
$event = Event::factory()->create(['user_id' => $this->user->id]);
$media = EventMedia::factory()->create([
'user_id' => $this->user->id,
'event_id' => $event->id,
'path' => 'event-media/1/original.jpg',
'preview_path' => 'event-media/1/preview.jpg',
'thumbnail_path' => 'event-media/1/thumb.jpg',
]);
Storage::disk('local')->put($media->path, 'original');
Storage::disk('local')->put($media->preview_path, 'preview');
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
$this->deleteJson("/api/events/{$event->client_id}")->assertNoContent();
Storage::disk('local')->assertMissing($media->path);
Storage::disk('local')->assertMissing($media->preview_path);
Storage::disk('local')->assertMissing($media->thumbnail_path);
$this->assertDatabaseMissing('event_media', ['id' => $media->id]);
});
test('deleting an event through sync removes its media files', function () {
Storage::fake('local');
$event = Event::factory()->create(['user_id' => $this->user->id]);
$media = EventMedia::factory()->create([
'user_id' => $this->user->id,
'event_id' => $event->id,
'path' => 'event-media/2/original.jpg',
'preview_path' => 'event-media/2/preview.jpg',
'thumbnail_path' => 'event-media/2/thumb.jpg',
]);
Storage::disk('local')->put($media->path, 'original');
Storage::disk('local')->put($media->preview_path, 'preview');
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
$this->postJson('/api/events/sync', [
'mutations' => [[
'action' => 'delete',
'eventId' => $event->client_id,
'payload' => null,
]],
])->assertOk();
Storage::disk('local')->assertMissing($media->path);
Storage::disk('local')->assertMissing($media->thumbnail_path);
});

View file

@ -168,3 +168,220 @@ test('can delete own media and files', function () {
Storage::disk('local')->assertMissing($media->path);
Storage::disk('local')->assertMissing($media->thumbnail_path);
});
/**
* Minimal MP4 container header. libmagic identifies the `ftyp` box as
* video/mp4, which is all the upload validation looks at.
*/
function fakeMp4(): string
{
return "\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2mp41".str_repeat("\0", 256);
}
/**
* Minimal WebM (EBML) header, as Chrome's MediaRecorder produces for audio.
* libmagic reports it as video/webm.
*/
function fakeWebm(): string
{
return "\x1A\x45\xDF\xA3\x9F\x42\x86\x81\x01\x42\xF7\x81\x01\x42\xF2\x81\x04\x42\xF3\x81\x08"
."\x42\x82\x84webm\x42\x87\x81\x02\x42\x85\x81\x02".str_repeat("\0", 256);
}
test('can upload a video to the gallery', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$response = $this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'gallery',
'file' => UploadedFile::fake()->createWithContent('clip.mp4', fakeMp4()),
]);
$response->assertCreated()
->assertJsonPath('data.collection', 'gallery')
->assertJsonPath('data.type', 'video')
->assertJsonPath('data.mimeType', 'video/mp4');
$media = EventMedia::query()->firstOrFail();
expect($media->type)->toBe('video')
->and($media->path)->toEndWith('.mp4');
Storage::disk('local')->assertExists($media->path);
// The header-only clip has no decodable frame: no poster, but the
// upload still succeeds and the client gets an explicit null.
expect($response->json('data.originalUrl'))->toBe("/event-media/{$media->id}/original");
if ($media->thumbnail_path === null) {
expect($response->json('data.thumbnailUrl'))->toBeNull()
->and($response->json('data.src'))->toBeNull();
}
});
test('key image rejects a video', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'key_image',
'file' => UploadedFile::fake()->createWithContent('clip.mp4', fakeMp4()),
])->assertUnprocessable()
->assertJsonValidationErrors(['file']);
expect(EventMedia::query()->count())->toBe(0);
});
test('an event can carry several voice notes', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$first = $this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
]);
$first->assertCreated()
->assertJsonPath('data.collection', 'voice_note')
->assertJsonPath('data.type', 'audio')
->assertJsonPath('data.caption', '')
->assertJsonPath('data.thumbnailUrl', null)
->assertJsonPath('data.previewUrl', null);
$firstMedia = EventMedia::query()->firstOrFail();
Storage::disk('local')->assertExists($firstMedia->path);
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
])->assertCreated();
expect(EventMedia::query()->where('collection', 'voice_note')->count())->toBe(2);
$this->assertDatabaseHas('event_media', ['id' => $firstMedia->id]);
Storage::disk('local')->assertExists($firstMedia->path);
});
test('a voice note can be uploaded together with its caption', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
// Written offline, sent with the file once the connection was back.
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'caption' => 'Aufgenommen im Zug, ohne Netz',
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
])
->assertCreated()
->assertJsonPath('data.caption', 'Aufgenommen im Zug, ohne Netz');
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'caption' => str_repeat('x', 501),
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
])->assertUnprocessable();
});
test('can set and clear the caption of own media', function () {
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'collection' => 'voice_note',
'type' => 'audio',
]);
Passport::actingAs($user);
$this->patchJson("/api/events/{$event->client_id}/media/{$media->id}", [
'caption' => 'Nachtrag: die Adresse war Hauptstraße 3',
])->assertOk()
->assertJsonPath('data.caption', 'Nachtrag: die Adresse war Hauptstraße 3');
$this->patchJson("/api/events/{$event->client_id}/media/{$media->id}", ['caption' => ''])
->assertOk()
->assertJsonPath('data.caption', '');
$this->patchJson("/api/events/{$event->client_id}/media/{$media->id}", [
'caption' => str_repeat('x', 501),
])->assertUnprocessable();
});
test('cannot caption media of another user or another event', function () {
$user = User::factory()->create();
$other = User::factory()->create();
$ownEvent = Event::factory()->create(['user_id' => $user->id]);
$otherEvent = Event::factory()->create(['user_id' => $other->id]);
$otherMedia = EventMedia::factory()->create(['user_id' => $other->id, 'event_id' => $otherEvent->id]);
Passport::actingAs($user);
$this->patchJson("/api/events/{$otherEvent->client_id}/media/{$otherMedia->id}", ['caption' => 'x'])
->assertNotFound();
// Own event, but the media hangs on someone else's event.
$this->patchJson("/api/events/{$ownEvent->client_id}/media/{$otherMedia->id}", ['caption' => 'x'])
->assertNotFound();
expect($otherMedia->fresh()->caption)->toBeNull();
});
test('voice note keeps the audio type the browser reported', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
// A real temp file with the browser's declared type, as a multipart
// upload from MediaRecorder arrives.
$path = tempnam(sys_get_temp_dir(), 'voice').'.webm';
file_put_contents($path, fakeWebm());
$file = new UploadedFile($path, 'sprachnotiz.webm', 'audio/webm', null, true);
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'file' => $file,
])->assertCreated()
->assertJsonPath('data.mimeType', 'audio/webm');
});
test('voice note rejects an image', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
Passport::actingAs($user);
$this->postJson("/api/events/{$event->client_id}/media", [
'collection' => 'voice_note',
'file' => UploadedFile::fake()->image('foto.jpg', 400, 300),
])->assertUnprocessable()
->assertJsonValidationErrors(['file']);
});
test('streams an original with its own mime type', function () {
Storage::fake('local');
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'type' => 'video',
'mime_type' => 'video/mp4',
'path' => 'event-media/test/clip.mp4',
'thumbnail_path' => null,
'preview_path' => null,
]);
Storage::disk('local')->put($media->path, fakeMp4());
Passport::actingAs($user);
$this->get("/api/event-media/{$media->id}/original")
->assertOk()
->assertHeader('Content-Type', 'video/mp4')
->assertHeader('Accept-Ranges', 'bytes');
// No poster frame: the image variants do not exist.
$this->get("/api/event-media/{$media->id}/thumb")->assertNotFound();
});

View file

@ -39,7 +39,7 @@ test('can filter events by since parameter', function () {
'updated_at' => now()->subHour(),
]);
$response = $this->getJson('/api/events?since=' . now()->subDay()->toISOString());
$response = $this->getJson('/api/events?since='.now()->subDay()->toISOString());
$response->assertOk()
->assertJsonCount(1, 'data');
@ -184,3 +184,99 @@ test('sync is idempotent for creates', function () {
expect(Event::where('client_id', $clientId)->count())->toBe(1);
});
test('can create an event with an empty title', function () {
// An event is created with its first entry, which may be a note or a
// photo. The title may therefore be empty, but it has to be sent.
$clientId = Str::uuid()->toString();
$this->postJson('/api/events', [
'id' => $clientId,
'title' => '',
'date' => '2024-06-15',
'emotion' => 0,
'note' => 'Nur eine Notiz',
])->assertCreated()
->assertJsonPath('data.title', '');
expect(Event::query()->where('client_id', $clientId)->value('title'))->toBe('');
});
test('title must still be sent when creating an event', function () {
$this->postJson('/api/events', [
'id' => Str::uuid()->toString(),
'date' => '2024-06-15',
'emotion' => 0,
])->assertUnprocessable()
->assertJsonValidationErrors(['title']);
});
test('stores category, people, a span and the milestone flag', function () {
$clientId = Str::uuid()->toString();
$this->postJson('/api/events', [
'id' => $clientId,
'title' => 'Umzug nach Hamburg',
'date' => '2019-04-01',
'endDate' => '2019-04-14',
'emotion' => 0.4,
'milestone' => true,
'category' => 'home',
'people' => ['Lena', ' Papa ', '', 'Lena'],
])
->assertCreated()
->assertJsonPath('data.endDate', '2019-04-14')
->assertJsonPath('data.milestone', true)
->assertJsonPath('data.category', 'home')
// Trimmed, blanks dropped, duplicates removed.
->assertJsonPath('data.people', ['Lena', 'Papa']);
// Clearing the span and the people list on update.
$this->putJson("/api/events/{$clientId}", [
'endDate' => null,
'people' => [],
'milestone' => false,
])
->assertOk()
->assertJsonPath('data.endDate', null)
->assertJsonPath('data.people', [])
->assertJsonPath('data.milestone', false);
});
test('rejects an unknown category and a span that ends before it starts', function () {
$base = ['id' => Str::uuid()->toString(), 'title' => 'x', 'date' => '2020-05-10', 'emotion' => 0];
$this->postJson('/api/events', [...$base, 'category' => 'aliens'])
->assertUnprocessable()
->assertJsonValidationErrors(['category']);
$this->postJson('/api/events', [...$base, 'endDate' => '2020-05-09'])
->assertUnprocessable()
->assertJsonValidationErrors(['endDate']);
$this->postJson('/api/events', [...$base, 'people' => [str_repeat('x', 81)]])
->assertUnprocessable()
->assertJsonValidationErrors(['people.0']);
});
test('sync writes the new fields like crud does', function () {
$clientId = Str::uuid()->toString();
$this->postJson('/api/events/sync', ['mutations' => [[
'action' => 'create',
'eventId' => $clientId,
'payload' => [
'title' => 'Abschluss',
'date' => '2015-07-01',
'emotion' => 0.9,
'milestone' => true,
'category' => 'education',
'people' => ['Prof. Weber'],
],
]]])->assertOk()->assertJsonPath('results.0.status', 'ok');
$event = Event::query()->where('client_id', $clientId)->firstOrFail();
expect($event->milestone)->toBeTrue()
->and($event->category)->toBe('education')
->and($event->people)->toBe(['Prof. Weber']);
});

View file

@ -0,0 +1,83 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Passport\ClientRepository;
beforeEach(function () {
RateLimiter::clear('api-login|throttle@example.com|127.0.0.1');
RateLimiter::clear('api-login|other@example.com|127.0.0.1');
// A successful login issues a personal access token.
app(ClientRepository::class)->createPersonalAccessGrantClient('Test Personal Access Client', 'users');
});
test('repeated failed logins are locked out with http 429', function () {
User::factory()->create([
'email' => 'throttle@example.com',
'password' => 'correct-horse',
]);
// Five wrong passwords are answered with the normal validation error.
foreach (range(1, 5) as $attempt) {
$this->postJson('/api/login', [
'email' => 'throttle@example.com',
'password' => 'wrong',
])->assertStatus(422);
}
// The sixth is refused before the password is ever checked.
$this->postJson('/api/login', [
'email' => 'throttle@example.com',
'password' => 'wrong',
])->assertStatus(429);
// Even the correct password stays locked out during the cooldown.
$this->postJson('/api/login', [
'email' => 'throttle@example.com',
'password' => 'correct-horse',
])->assertStatus(429);
});
test('a successful login clears the failure counter', function () {
User::factory()->create([
'email' => 'throttle@example.com',
'password' => 'correct-horse',
]);
foreach (range(1, 4) as $attempt) {
$this->postJson('/api/login', [
'email' => 'throttle@example.com',
'password' => 'wrong',
])->assertStatus(422);
}
$this->postJson('/api/login', [
'email' => 'throttle@example.com',
'password' => 'correct-horse',
])->assertOk();
// The counter is back to zero, so a fresh run of wrong attempts is
// answered with 422 again rather than an immediate lockout.
$this->postJson('/api/login', [
'email' => 'throttle@example.com',
'password' => 'wrong',
])->assertStatus(422);
});
test('the throttle is scoped per email so one account cannot lock out another', function () {
User::factory()->create(['email' => 'throttle@example.com', 'password' => 'correct-horse']);
User::factory()->create(['email' => 'other@example.com', 'password' => 'correct-horse']);
foreach (range(1, 6) as $attempt) {
$this->postJson('/api/login', [
'email' => 'throttle@example.com',
'password' => 'wrong',
]);
}
$this->postJson('/api/login', [
'email' => 'other@example.com',
'password' => 'correct-horse',
])->assertOk();
});

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Route;
function middlewareOf(string $method, string $uri): array
{
$route = collect(Route::getRoutes()->getRoutes())
->first(fn ($route) => in_array($method, $route->methods(), true) && $route->uri() === $uri);
expect($route)->not->toBeNull("route {$method} {$uri} is missing");
return $route->gatherMiddleware();
}
test('media reads have their own rate limit, apart from the api writes', function () {
foreach (['api/event-media/{media}/{variant}', 'api/settings/media/background'] as $uri) {
$middleware = middlewareOf('GET', $uri);
expect($middleware)->toContain('auth:api')
->toContain('throttle:media')
->not->toContain('throttle:api');
}
});
test('api writes and client logs keep their limits', function () {
expect(middlewareOf('PUT', 'api/settings'))->toContain('throttle:api');
expect(middlewareOf('GET', 'api/events'))->toContain('throttle:api');
$clientLogs = middlewareOf('POST', 'api/client-logs');
expect($clientLogs)->toContain('auth:api')
->toContain('throttle:client-logs')
->not->toContain('throttle:api');
});

View file

@ -0,0 +1,119 @@
<?php
use App\Models\User;
use Laravel\Passport\Passport;
beforeEach(function () {
$this->user = User::factory()->create();
Passport::actingAs($this->user);
});
test('a preset that is not an object is rejected', function () {
// `presets: [null]` used to satisfy the bare `array` rule and then blew up
// in the frontend when it read `preset.id`.
$this->putJson('/api/settings', [
'settings' => ['presets' => [null]],
])->assertStatus(422)->assertJsonValidationErrors('settings.presets.0');
});
test('a preset without an id or name is rejected', function () {
$this->putJson('/api/settings', [
'settings' => ['presets' => [['settings' => []]]],
])->assertStatus(422)
->assertJsonValidationErrors(['settings.presets.0.id', 'settings.presets.0.name']);
});
test('a valid preset is accepted', function () {
$this->putJson('/api/settings', [
'settings' => [
'presets' => [[
'id' => 'e0a1b2c3',
'name' => 'Nachtmodus',
'updatedAt' => 1726560000000,
'settings' => ['floatingLines' => ['speed' => 1.5, 'lineMode' => 'glow']],
]],
'activePresetId' => 'e0a1b2c3',
],
])->assertOk()->assertJsonPath('data.activePresetId', 'e0a1b2c3');
});
test('a preset keeps every snapshot field when stored', function () {
// Nested rules make validated() drop any key without an explicit rule, so a
// preset used to come back as `{floatingLines: ...}` only.
$snapshot = [
'theme' => 'dark',
'appearance' => 'dark',
'accentColor' => 'coral',
'language' => 'en',
'emotionGradientStart' => '#112233',
'emotionGradientEnd' => '#445566',
'timelineZoom' => 1.75,
'showFps' => true,
'floatingLines' => ['speed' => 1.5, 'lineMode' => 'glow'],
];
$this->putJson('/api/settings', [
'settings' => [
'presets' => [['id' => 'p1', 'name' => 'Nachtmodus', 'updatedAt' => 1726560000000, 'settings' => $snapshot]],
],
])->assertOk();
expect($this->user->fresh()->settings->settings['presets'][0]['settings'])->toBe($snapshot);
});
test('unknown top level settings keys are rejected instead of stored', function () {
$this->putJson('/api/settings', [
'settings' => ['theme' => 'dark', 'isAdmin' => true],
])->assertStatus(422)->assertJsonValidationErrors('settings');
expect($this->user->fresh()->settings)->toBeNull();
});
test('unknown floating line keys are rejected', function () {
$this->putJson('/api/settings', [
'settings' => ['floatingLines' => ['speed' => 1.0, 'somethingElse' => 5]],
])->assertStatus(422)->assertJsonValidationErrors('settings.floatingLines');
});
test('a nested structure inside floating lines is rejected', function () {
$this->putJson('/api/settings', [
'settings' => ['floatingLines' => ['speed' => ['nested' => 'payload']]],
])->assertStatus(422)->assertJsonValidationErrors('settings.floatingLines.speed');
});
test('an oversized string inside floating lines is rejected', function () {
$this->putJson('/api/settings', [
'settings' => ['floatingLines' => ['gradientStops' => str_repeat('x', 5000)]],
])->assertStatus(422)->assertJsonValidationErrors('settings.floatingLines.gradientStops');
});
test('more presets than the schema allows are rejected', function () {
$presets = collect(range(1, 51))->map(fn (int $n) => [
'id' => "preset-{$n}",
'name' => "Preset {$n}",
'settings' => [],
])->all();
$this->putJson('/api/settings', ['settings' => ['presets' => $presets]])
->assertStatus(422)
->assertJsonValidationErrors('settings.presets');
});
test('known floating line values still pass through unchanged', function () {
$this->putJson('/api/settings', [
'settings' => [
'theme' => 'dark',
'language' => 'de',
'timelineZoom' => 2.5,
'showFps' => false,
'floatingLines' => [
'speed' => 1.25,
'lineCount' => 10,
'bgCenter' => '#0a0514',
'lineMode' => 'glow',
],
],
])->assertOk()
->assertJsonPath('data.floatingLines.speed', 1.25)
->assertJsonPath('data.floatingLines.lineMode', 'glow');
});

View file

@ -0,0 +1,82 @@
<?php
use App\Models\Event;
use App\Models\User;
use Database\Seeders\LifelineSeeder;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('local');
$this->imageDirectory = sys_get_temp_dir().'/lifeline-images-test-'.uniqid();
File::ensureDirectoryExists($this->imageDirectory);
});
afterEach(function () {
File::deleteDirectory($this->imageDirectory);
});
function fakeLifelineImage(string $path): void
{
$image = imagecreatetruecolor(64, 48);
imagefill($image, 0, 0, imagecolorallocate($image, 200, 160, 90));
imagepng($image, $path);
imagedestroy($image);
}
test('import attaches key images to seeded lifeline events by age and month', function () {
$this->seed(LifelineSeeder::class);
foreach (['04-07', '10-02'] as $slug) {
fakeLifelineImage("{$this->imageDirectory}/{$slug}.png");
}
$this->artisan('lifelines:import-images', ['handle' => 'bruno', '--from' => $this->imageDirectory])
->expectsOutputToContain('Imported 2, skipped 0')
->assertSuccessful();
$bruno = User::query()->where('email', 'bruno@thats-me.app')->firstOrFail();
$event = Event::query()
->where('client_id', LifelineSeeder::clientId('bruno@thats-me.app', 4, 7))
->firstOrFail();
$media = $event->media()->where('collection', 'key_image')->firstOrFail();
expect($event->image)->toBe("/event-media/{$media->id}/thumb")
->and($media->user_id)->toBe($bruno->id)
->and($media->mime_type)->toBe('image/jpeg');
Storage::disk('local')->assertExists([$media->path, $media->preview_path, $media->thumbnail_path]);
});
test('import skips events that already have a key image unless forced', function () {
$this->seed(LifelineSeeder::class);
fakeLifelineImage("{$this->imageDirectory}/04-07.png");
$this->artisan('lifelines:import-images', ['handle' => 'bruno', '--from' => $this->imageDirectory])->assertSuccessful();
$this->artisan('lifelines:import-images', ['handle' => 'bruno', '--from' => $this->imageDirectory])
->expectsOutputToContain('Imported 0, skipped 1')
->assertSuccessful();
$event = Event::query()
->where('client_id', LifelineSeeder::clientId('bruno@thats-me.app', 4, 7))
->firstOrFail();
$firstMedia = $event->media()->where('collection', 'key_image')->firstOrFail();
$this->artisan('lifelines:import-images', ['handle' => 'bruno', '--from' => $this->imageDirectory, '--force' => true])
->expectsOutputToContain('Imported 1')
->assertSuccessful();
expect($event->media()->where('collection', 'key_image')->count())->toBe(1)
->and($event->media()->where('collection', 'key_image')->first()->id)->not->toBe($firstMedia->id);
Storage::disk('local')->assertMissing($firstMedia->path);
});
test('import fails when the lifeline user is not seeded', function () {
$this->artisan('lifelines:import-images', ['handle' => 'bruno', '--from' => $this->imageDirectory])
->expectsOutputToContain('not seeded yet')
->assertFailed();
});

View file

@ -0,0 +1,102 @@
<?php
use App\Models\Event;
use App\Models\EventMedia;
use App\Models\User;
use App\Services\EventMediaProbe;
use Illuminate\Support\Facades\Storage;
function legacyVoiceNote(string $mime = 'audio/webm', string $extension = 'webm'): EventMedia
{
$user = User::factory()->create();
$event = Event::factory()->create(['user_id' => $user->id]);
$path = "event-media/{$user->id}/{$event->client_id}/legacy.{$extension}";
Storage::disk('local')->put($path, 'not really audio');
return EventMedia::factory()->create([
'event_id' => $event->id,
'user_id' => $user->id,
'collection' => 'voice_note',
'type' => 'audio',
'mime_type' => $mime,
'path' => $path,
'disk' => 'local',
'thumbnail_path' => null,
'preview_path' => null,
]);
}
test('converts legacy webm voice notes to audio/mp4 and drops the old file', function () {
Storage::fake('local');
$media = legacyVoiceNote();
$oldPath = $media->path;
$probe = Mockery::mock(EventMediaProbe::class);
$probe->shouldReceive('transcodeAudioToM4a')
->once()
->withArgs(function (string $source, string $target) use ($oldPath): bool {
expect($source)->toEndWith($oldPath);
expect($target)->toEndWith('legacy.m4a');
file_put_contents($target, 'aac bytes');
return true;
})
->andReturn(true);
$probe->shouldReceive('durationMs')->once()->andReturn(4200);
$this->app->instance(EventMediaProbe::class, $probe);
$this->artisan('media:transcode-voice-notes')
->expectsOutputToContain('1 voice note(s) to convert.')
->expectsOutputToContain('Converted 1, failed 0.')
->assertSuccessful();
$media->refresh();
expect($media->mime_type)->toBe('audio/mp4')
->and($media->path)->toEndWith('legacy.m4a')
->and($media->duration_ms)->toBe(4200)
->and($media->size)->toBe(strlen('aac bytes'));
Storage::disk('local')->assertExists($media->path);
Storage::disk('local')->assertMissing($oldPath);
});
test('leaves audio/mp4 voice notes alone and reports when nothing is to do', function () {
Storage::fake('local');
legacyVoiceNote('audio/mp4', 'm4a');
$probe = Mockery::mock(EventMediaProbe::class);
$probe->shouldNotReceive('transcodeAudioToM4a');
$this->app->instance(EventMediaProbe::class, $probe);
$this->artisan('media:transcode-voice-notes')
->expectsOutputToContain('already audio/mp4')
->assertSuccessful();
});
test('dry run only lists what would change', function () {
Storage::fake('local');
$media = legacyVoiceNote();
$probe = Mockery::mock(EventMediaProbe::class);
$probe->shouldNotReceive('transcodeAudioToM4a');
$this->app->instance(EventMediaProbe::class, $probe);
$this->artisan('media:transcode-voice-notes --dry-run')
->expectsOutputToContain("#{$media->id}: audio/webm → audio/mp4")
->assertSuccessful();
expect($media->fresh()->mime_type)->toBe('audio/webm');
});
test('a failed conversion keeps the original and exits with failure', function () {
Storage::fake('local');
$media = legacyVoiceNote();
$probe = Mockery::mock(EventMediaProbe::class);
$probe->shouldReceive('transcodeAudioToM4a')->once()->andReturn(false);
$this->app->instance(EventMediaProbe::class, $probe);
$this->artisan('media:transcode-voice-notes')->assertFailed();
expect($media->fresh()->mime_type)->toBe('audio/webm');
Storage::disk('local')->assertExists($media->path);
});

View file

@ -0,0 +1,127 @@
<?php
use App\Models\Event;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Database\Seeders\LifelineSeeder;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Hash;
function seededLifelineEventCount(): int
{
return collect(LifelineSeeder::lifelineFiles())
->sum(fn (string $path): int => count(json_decode(File::get($path), true)['events']));
}
test('lifeline seeder creates jonas and sophie with a full lifeline each', function () {
$this->seed(LifelineSeeder::class);
foreach (['jonas' => 'Jonas', 'sophie' => 'Sophie'] as $handle => $name) {
$user = User::query()->where('email', "{$handle}@thats-me.app")->first();
expect($user)->not->toBeNull()
->and($user->name)->toBe($name)
->and(Hash::check('pass', $user->password))->toBeTrue()
->and($user->email_verified_at)->not->toBeNull();
$events = Event::query()->where('user_id', $user->id)->orderBy('date')->get();
// 21 years (25–45) × 12 months, one event per month
expect($events)->toHaveCount(252)
->and($events->first()->date)->toStartWith('2005-01-')
->and($events->last()->date)->toStartWith('2025-12-')
->and($events->pluck('date')->map(fn (string $date): string => substr($date, 0, 7))->unique())->toHaveCount(252);
foreach ($events as $event) {
expect($event->title)->not->toBe('')
->and(mb_strlen($event->title))->toBeLessThanOrEqual(40)
->and($event->note)->not->toBe('')
->and((float) $event->emotion)->toBeGreaterThanOrEqual(-1)
->and((float) $event->emotion)->toBeLessThanOrEqual(1)
->and($event->image)->toBeNull();
}
}
});
test('lifeline seeder maps ratings onto the emotion bands of the event panel', function () {
$this->seed(LifelineSeeder::class);
$jonas = User::query()->where('email', 'jonas@thats-me.app')->firstOrFail();
$emotionFor = fn (int $age, int $month): float => (float) Event::query()
->where('client_id', LifelineSeeder::clientId('jonas@thats-me.app', $age, $month))
->where('user_id', $jonas->id)
->value('emotion');
// 25 / Juli: Lagerfeuer in Slowenien → Sehr positiv
expect($emotionFor(25, 7))->toBeGreaterThan(0.5);
// 25 / Januar: Erstes eigenes Projekt → Positiv
expect($emotionFor(25, 1))->toBeGreaterThan(0.15)->toBeLessThanOrEqual(0.5);
// 25 / Februar: Sonntags-Vorkochen → Neutral
expect($emotionFor(25, 2))->toBeGreaterThan(-0.15)->toBeLessThanOrEqual(0.15);
// 25 / Juni: Spätschicht im Büro → Negativ
expect($emotionFor(25, 6))->toBeGreaterThan(-0.5)->toBeLessThanOrEqual(-0.15);
// 40 / Mai: Letztes Foto von Thomas → Sehr negativ
expect($emotionFor(40, 5))->toBeLessThanOrEqual(-0.5);
});
test('lifeline seeder is idempotent and keeps manually imported key images', function () {
$this->seed(LifelineSeeder::class);
$clientId = LifelineSeeder::clientId('sophie@thats-me.app', 35, 1);
$event = Event::query()->where('client_id', $clientId)->firstOrFail();
$event->update([
'image' => 'media/sophie/clara.jpg',
'key_image_title' => 'Clara',
'title' => 'Manuell geändert',
]);
$this->seed(LifelineSeeder::class);
$event->refresh();
expect(Event::query()->count())->toBe(seededLifelineEventCount())
->and(User::query()->where('email', 'like', '%@thats-me.app')->count())->toBe(count(LifelineSeeder::lifelineFiles()))
->and($event->image)->toBe('media/sophie/clara.jpg')
->and($event->key_image_title)->toBe('Clara')
->and($event->title)->toBe('Clara ist da');
});
test('database seeder includes the lifelines', function () {
$this->seed(DatabaseSeeder::class);
foreach (['jonas', 'sophie', 'nadja', 'werner', 'bruno', 'benz', 'lukas', 'nachtschicht'] as $handle) {
expect(User::query()->where('email', "{$handle}@thats-me.app")->exists())->toBeTrue();
}
expect(Event::query()->count())->toBe(seededLifelineEventCount());
});
test('additional lifelines have gaps and end at their final year', function () {
$this->seed(LifelineSeeder::class);
$range = function (string $handle): array {
$user = User::query()->where('email', "{$handle}@thats-me.app")->firstOrFail();
$events = Event::query()->where('user_id', $user->id);
return [$events->count(), substr($events->min('date'), 0, 4), substr($events->max('date'), 0, 4)];
};
// Nadja: 1988 + 24 … 37, Werner: 1955 + 55 … 70, Bruno: 2014 + 0 … 10
expect($range('nadja'))->toBe([42, '2012', '2025'])
->and($range('werner'))->toBe([41, '2010', '2025'])
->and($range('bruno'))->toBe([35, '2014', '2024'])
->and($range('benz'))->toBe([37, '1981', '2025'])
->and($range('lukas'))->toBe([69, '2012', '2025'])
->and($range('nachtschicht'))->toBe([57, '1995', '2025']);
});
test('an explicit day separates several events in the same month', function () {
$email = 'nadja@thats-me.app';
expect(LifelineSeeder::clientId($email, 32, 5))->not->toBe(LifelineSeeder::clientId($email, 32, 5, 12))
->and(LifelineSeeder::clientId($email, 32, 5, 12))->not->toBe(LifelineSeeder::clientId($email, 32, 5, 27))
->and(LifelineSeeder::date(1988, 32, 5, $email, 12))->toBe('2020-05-12')
->and(LifelineSeeder::date(1988, 32, 5, $email))->toMatch('/^2020-05-(0[1-9]|1\d|2[0-8])$/');
});

View file

@ -0,0 +1,42 @@
<?php
use App\Models\Event;
use App\Models\EventMedia;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt;
test('deleting a user removes their media files and settings background', function () {
Storage::fake('local');
$user = User::factory()->create(['password' => 'password']);
$event = Event::factory()->create(['user_id' => $user->id]);
$media = EventMedia::factory()->create([
'user_id' => $user->id,
'event_id' => $event->id,
'path' => "event-media/{$user->id}/original.jpg",
'preview_path' => "event-media/{$user->id}/preview.jpg",
'thumbnail_path' => "event-media/{$user->id}/thumb.jpg",
]);
$background = "settings-media/{$user->id}/background.jpg";
Storage::disk('local')->put($media->path, 'original');
Storage::disk('local')->put($media->preview_path, 'preview');
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
Storage::disk('local')->put($background, 'background');
$this->actingAs($user);
Volt::test('settings.delete-user-form')
->set('password', 'password')
->call('deleteUser')
->assertHasNoErrors();
Storage::disk('local')->assertMissing($media->path);
Storage::disk('local')->assertMissing($media->preview_path);
Storage::disk('local')->assertMissing($media->thumbnail_path);
Storage::disk('local')->assertMissing($background);
expect($user->fresh())->toBeNull();
});

443
dev/AUDIT-2026-09-17.md Normal file
View file

@ -0,0 +1,443 @@
# Projektaudit: Quasar-Frontend und Laravel-Backend
**Datum:** 17.09.2026
**Geprüfter Commit:** `f79dcb992839cba04e824b3d9e23231efa7b6bdc` vom 07.07.2026
**Umfang:** Entwicklungsstand, aktive Anwendung, API, Persistenz, Authentifizierung, Medien, Abhängigkeiten und Qualitätssicherung.
**Ergebnis dieser Arbeit:** Audit und Maßnahmenvorschläge; keine Änderungen an Anwendungscode, Datenmodell oder Abhängigkeiten.
**Kontextnachtrag vom 17.09.2026:** Projektziel und Maßnahmenreihenfolge nach Rückmeldung des Projektverantwortlichen angepasst. Die technischen Befunde und bisherigen Prüfergebnisse bleiben bestehen; ihre Dringlichkeit wird nach Entwicklungsphase eingeordnet. Für diesen Nachtrag wurden Auth-Store und Seeder erneut gelesen, keine Builds, Tests oder Registry-Prüfungen wiederholt.
## 1. Einschätzung
Das Projekt befindet sich nach Auskunft des Projektverantwortlichen in Entwicklung und wird aktuell getestet. Das nächste Ziel ist eine kleine Demo, die das Produkt vorstellt und Interesse weckt. Social Media soll dabei helfen, Interessenten und spätere Kunden zu erreichen. Bei ausreichendem Interesse kommt eine Crowdfunding-Kampagne infrage; sie ist noch keine beschlossene Projektphase.
Aktuell gibt es keine regulären Kundenkonten im vorgesehenen Testbetrieb. Die sechs auswählbaren API-Konten dienen bewusst dazu, serverseitiges Speichern und Benutzerwechsel zu testen. Hinzu kommt ein lokaler Demo-Zugang ohne API-Anmeldung. Dieses Testmodell passt zum Entwicklungsziel; sechs feste Testkonten und vereinfachte Zugangsdaten sind für sich genommen kein Entwicklungsfehler.
Das Projekt hat eine brauchbare Grundlage für diese Demo. Die Quasar-Oberfläche, lokale Demo-Daten, API-Anmeldung, Event-CRUD, Benutzereinstellungen und geschützte Bildauslieferung sind implementiert. Die serverseitige Einschränkung der meisten Datenzugriffe auf den angemeldeten Benutzer ist sauber erkennbar und durch API-Tests teilweise abgesichert.
**Für den nächsten Entwicklungsschritt stehen zuverlässige Demo-Abläufe im Vordergrund:** Speichern und Wiederladen, vollständige Event-Felder, korrektes Umschalten zwischen Testkonten und eine verständliche Fehleranzeige. Die nachgewiesenen Speicher- und Benutzerwechselprobleme beeinträchtigen bereits die Testbarkeit und eine Präsentation mit Beispieldaten. Anforderungen an einen späteren Betrieb mit echten privaten Konten werden gesondert vorgemerkt.
**Prioritäten:**
- **P1 – hoch:** im jeweils genannten Nutzungsszenario vorrangig bearbeiten; aktuell vor allem wesentliche Demo- und Speicherfehler, bei öffentlichem Betrieb zusätzlich erreichbare Sicherheitsrisiken.
- **P2 – mittel:** im nächsten Stabilisierungsschritt beheben; teilweise abhängig von Fehlersituationen, Datenmenge oder Nutzung.
- **P3 – niedrig / Optimierung:** Wartbarkeit, Performance und Entwicklungsabläufe verbessern.
### 1.1 Bewertung nach Entwicklungsphase
| Phase | Schwerpunkt | Konsequenz für diesen Audit |
| --- | --- | --- |
| Aktueller Teststand mit sechs API-Testkonten und lokalem Demo-Zugang | Persistenz, Darstellung und Benutzerwechsel überprüfen | Testkonten beibehalten; A04–A08 als Funktionsfehler priorisieren. Vollständige Kundenregistrierung oder ein ausgebautes Kontensystem sind dafür nicht erforderlich. |
| Geplante Werbedemo | Produkt verständlich und zuverlässig präsentieren | Bei einer betreuten Präsentation stehen die gezeigten Abläufe im Vordergrund. Falls Besucher selbst API-Konten und Uploads nutzen dürfen, zusätzlich Missbrauch und Veränderung gemeinsamer Beispieldaten berücksichtigen. Das spätere Zugangsmodell ist noch offen. |
| Öffentliche Erreichbarkeit von API oder Entwicklungsdiensten | Verfügbarkeit und Schutz der Infrastruktur | Sicherheitsupdates für erreichbare Schwachstellen, Zugriffsschutz sowie angemessene Request-/Upload-Limits werden auch ohne echte Kundenkonten relevant. Ob die derzeitige Testinstallation öffentlich erreichbar ist, wurde nicht geprüft. |
| Möglicher späterer Betrieb mit echten Konten und privaten Inhalten | Verbindliche Benutzertrennung, Zugriffsschutz und Datenlebenszyklus | Demo-Seeding von echten Konten trennen und die vorgemerkten Sicherheits-/Betriebsanforderungen vor dieser Nutzung abarbeiten. |
Die Nutzerangaben beschreiben den vorgesehenen Testbetrieb; Datenbankinhalte und Live-Erreichbarkeit wurden nicht kontrolliert. Es wurde kein Angriff auf einen Live-Server ausgeführt. Ein verwundbarer Paketstand oder ein Seeder ohne Umgebungsschutz ist kein Beweis dafür, dass eine Installation bereits kompromittiert ist.
## 2. Entwicklung und tatsächlicher Stand
### 2.1 Verlauf
| Stand | Entwicklung | Einordnung |
| --- | --- | --- |
| 20.02.2026, `98084de` | LifeWave-Oberfläche, Timeline, Shader, Event-Panel und mobile Vorarbeiten | Schwerpunkt auf Darstellung und Interaktion |
| 25.02.2026, `70a7776` | Passport, Event-API, Migrationen, API-Tests, IndexedDB und Synchronisationsservice | Beginn der Backend-/Offline-Anbindung |
| April 2026 | Weitere UI-/Medienarbeit und zusätzliche Prototypquellen | Mehrere Entwicklungsstände liegen nebeneinander |
| 05.06.2026, `c151499` | Hybrid-Anmeldung, Remote-Events, Settings und geschützte Medien | Aktueller wesentlicher Integrationsstand |
| 07.07.2026, `f79dcb9` | Performance- und Sync-Anpassungen überwiegend unter `frontend/_src` | Änderungen dort gehören nicht automatisch zur aktiven App |
Maßgeblich sind [Router](../frontend/src/router/routes.js) und [Quasar-Konfiguration](../frontend/quasar.config.js): Die aktive Oberfläche verwendet `src/pages/LifeWavePage.vue` und `src/layouts/LifeWaveLayout.vue`. `frontend/_src`, `frontend/src/legacy`, `dot-line-system` und die HTML-Experimente unter `test` sind keine gleichwertigen Teile dieses produktiven Ausführungspfads.
### 2.2 Architektur
| Bereich | Tatsächliches Verhalten |
| --- | --- |
| Frontend | Quasar 2 / Vue 3, Pinia, Vue Router mit Hash-Routing, Vite, Dexie, Three.js |
| Lokaler Demo-Benutzer | Events und Medien in IndexedDB, Settings in localStorage; neue Demo-Mutationen werden nicht in die API-SyncQueue geschrieben |
| Sechs Remote-Testbenutzer | Bewusst persistierende API-Konten für Events, Settings und Uploads; IndexedDB/localStorage dienen zusätzlich als Cache |
| Authentifizierung | Passport-Bearer-Token in IndexedDB; Benutzerprofil zusätzlich in localStorage |
| Backend | Laravel 12 mit Passport; daneben Website und Livewire-/Volt-Einstellungen |
| Medien | Private Dateien, benutzergebundene Auslieferung; Thumbnail, Preview und verkleinertes Original |
| Synchronisation | Separater älterer Service existiert; kein durchgehend funktionierender Offline-Schreibpfad für die aktive Remote-App |
| Infrastruktur | Compose mit PHP 8.4 und Node 20; externe Dienste und externes `proxy`-Netz werden vorausgesetzt |
Aus den Lockfiles: Quasar `2.18.6`, Vue `3.5.29`, Pinia `3.0.4`, Dexie `4.3.0`, Three.js `0.183.1`, Frontend-Vite `7.3.1`; Laravel `12.52.0`, Passport `13.5.0`, Livewire `4.1.4`, Volt `1.10.2`, Pest `3.8.5`, Backend-Vite `6.4.0`.
Der erneute Codeabgleich bestätigt die sechs auswählbaren Remote-Testkonten und `local-demo` im [Auth-Store](../frontend/src/stores/auth.js). Der [Backend-Seeder](../backend/database/seeders/DatabaseSeeder.php) legt daneben einen zusätzlichen allgemeinen `Test User` an, der nicht zur sechs Einträge umfassenden Frontend-Auswahl gehört. Der lokale Demo-Zugang wird im Frontend definiert und ist kein siebtes API-Konto. Damit sind lokale Demo-Speicherung und serverseitige Persistenztests bewusst getrennte Abläufe.
### 2.3 Dokumentation weicht vom Code ab
Der [Implementierungsplan](../frontend/dev/db-api-connect/implementation-plan.md) bezeichnet wesentliche Phasen als abgeschlossen und fordert die Speicherung von `location` und `key_image_title`. Diese Felder fehlen weiterhin im Backend. Außerdem verweist der Sync-Nachtrag vom Juli ausdrücklich auf `_src`; die aktive Implementierung unter `src` enthält diese Änderungen nicht.
`frontend/package.json`, `appVersion.js` und das Root-README nennen `0.0.1`; der Implementierungsplan nennt `0.5.0`, das Migrationsprotokoll weitere Zwischenversionen. Historische Versionsangaben sind sinnvoll, müssen aber klar vom aktuellen Release-Stand unterschieden werden. Ein konsistenter Implementierungsstatus und ein verbindlicher API-Vertrag fehlen.
## 3. Ausgeführte Prüfungen
| Prüfung | Ergebnis |
| --- | --- |
| Frontend: `npm run lint` | Erfolgreich |
| Frontend: `npm run build` | Erfolgreich; Warnung wegen eines großen JavaScript-Chunks |
| Backend: `npm run build` | Erfolgreich; leerer `app`-JS-Chunk gemeldet |
| Backend: `php artisan test --compact` | **62 bestanden, 1 fehlgeschlagen, 210 Assertions**; Fehler bei der Profilseite, siehe A17; beim zweiten Lauf bestätigt |
| Backend: `php artisan route:list --path=api -v` | 18 API-Routen; kein Login-/API-Throttle in der registrierten Middleware sichtbar |
| Frontend: `npm audit --json` | 21 betroffene Pakete: 13 high, 5 moderate, 3 low |
| Backend-Assets: `npm audit --json` | 11 betroffene Pakete: 1 critical, 9 high, 1 moderate |
| Backend-PHP: `composer audit --locked --format=json` | 45 Advisory-Einträge zu 14 Paketen; siehe A01 |
| Event-Store: Wechsel vor Ablauf des Debounce | Änderungsverlust isoliert reproduziert, siehe A06 |
| Event-Store: Antworten in umgekehrter Reihenfolge beim Benutzerwechsel | Falscher Benutzerkontext isoliert reproduziert, siehe A04 |
| Datum in `America/Los_Angeles` | `2026-01-01` wird mit aktueller Konvertierung als `31.12.2025` dargestellt, siehe A18 |
Die Checks liefen mit lokal vorhandenem Node `24.14.1` und PHP `8.4.19`. Es wurden keine Paketupdates installiert. PHPUnit erzwingt SQLite `:memory:`; diese Ergebnisse ersetzen keinen MySQL-Integrationstest. Die API-Tests innerhalb der Suite meldeten keinen Fehler.
Für die beiden Store-Reproduktionen wurde der tatsächliche `events.js`-Funktionscode mit Vue-Reaktivität ausgeführt; API, Datenbankzugriffe und Store-Registrierung wurden kontrolliert ersetzt. Das prüft die Ablaufprobleme, ersetzt jedoch keinen Browser-/IndexedDB-End-to-End-Test. Es entstanden keine zusätzlichen Testdateien im Repository.
Nicht durchgeführt: vollständige Browser-/Mobilgeräte-QA, Lasttest, Live-Penetrationstest, Prüfung produktiver Konfiguration, Backups oder realer Nutzerdaten. Die Paketmeldungen stammen aus den am Auditdatum abgefragten Registern; ihre Zahl kann sich ändern. `npm test` im Frontend wurde nicht als Testnachweis verwendet, da das Skript lediglich erfolgreich beendet.
## 4. Kernbefunde und phasenabhängige Prioritäten
### A01 – Bekannte Sicherheitslücken in Abhängigkeiten
**Priorität:** P2 im abgeschirmten Entwicklungsbetrieb; P1 für erreichbare relevante Schwachstellen vor öffentlicher Nutzung. **Nachweis:** Registry-Prüfungen des ursprünglichen Audits; konkrete Ausnutzbarkeit je Advisory gesondert bewerten.
**Einordnung für die Demo:** Paketmeldungen bedeuten nicht, dass zuerst ein vollständiges Produktionssystem aufgebaut werden muss. Erreichbarkeit und betroffener Ausführungspfad bestimmen die Dringlichkeit. Öffentlich erreichbare Entwicklungsdienste können auch bei ausschließlichen Testkonten ein Risiko für die Infrastruktur sein.
Die drei Paketprüfungen melden bekannte Schwachstellen. Besonders relevant:
| Paket / Bereich | Befund und Bedeutung |
| --- | --- |
| Passport `13.5.0` | Liegt im betroffenen Bereich einer TokenGuard-Lücke für `client_credentials`-Tokens; Fix für dieses Advisory ab `13.7.1`. Die dokumentierte Benutzerverwechslung hängt unter anderem von Client-/Benutzer-IDs und verwendeten Grants ab. Im Projekt werden Personal-Access-Tokens erzeugt; eine konkrete Ausnutzung wurde nicht nachgewiesen. [Hersteller-Advisory](https://github.com/laravel/passport/security/advisories/GHSA-349c-2h2f-mxf6) |
| Vite `7.3.1` / `6.4.0` | Bekannter Dateizugriff über den WebSocket eines erreichbaren Dev-Servers. Compose veröffentlicht Entwicklungsports und routet Entwicklungsdienste über Traefik. Eine tatsächliche externe Erreichbarkeit wurde nicht geprüft. Das Advisory betrifft den Dev-Server, nicht automatisch die ausgelieferte statische SPA. [Hersteller-Advisory](https://github.com/vitejs/vite/security/advisories/GHSA-p9ff-h696-f583) |
| Quasar `2.18.6` | Prototype Pollution in `extend()`; Fix für dieses Advisory ab `2.22.0`. Voraussetzung ist ein geeigneter Deep-Merge mit kontrollierbaren Eingaben; kein entsprechender Exploitpfad in dieser App bestätigt. [Hersteller-Advisory](https://github.com/quasarframework/quasar/security/advisories/GHSA-3r53-75j5-3g7j) |
| Weitere Pakete | Unter anderem Laravel, Guzzle, CommonMark, Livewire, Symfony-Komponenten sowie Build-Werkzeuge. Der kritische npm-Befund im Backend betrifft `shell-quote` und ist kein Nachweis einer kritischen Lücke in einer Laravel-API-Route. |
Die Composer-Ausgabe enthält 45 Meldungseinträge: 15 high, 24 medium, 5 low und einen ohne Schweregrad. Einträge können denselben Sachverhalt aus unterschiedlichen Quellen betreffen; die Zahl bezeichnet keine 45 bestätigten unabhängigen Angriffe auf die Anwendung.
**Maßnahme:** Sicherheitsupdates in einem separaten, überprüfbaren Änderungsschritt einspielen, Lockfiles aktualisieren und danach Builds, API-Tests und Browser-Abläufe prüfen. Laufzeitpakete und nur beim Entwickeln verwendete Werkzeuge getrennt priorisieren. Dev-Server nur für den vorgesehenen Entwicklungskreis erreichbar machen. Die genannten Fixversionen schließen jeweils das zitierte Advisory, nicht zwangsläufig alle inzwischen gemeldeten Lücken.
### A02 – Bewusste Testzugänge und spätere Trennung vom Echtbetrieb
**Priorität:** P3 als Vorbereitung im aktuellen Teststand; P1 vor einer Nutzung derselben Umgebung mit echten privaten Konten/Inhalten. **Nachweis:** Code, bestehender Seeder-Test und Klarstellung des Projektverantwortlichen.
**Fundstellen:** [DatabaseSeeder.php](../backend/database/seeders/DatabaseSeeder.php), Z. 16–34; [LoginPage.vue](../frontend/src/pages/LoginPage.vue), Z. 62–76; [Deployment-Protokoll](../frontend/dev/db-api-connect/migration-log.md), Abschnitt 05.06.2026.
Die sechs festen API-Zugänge sind ausdrücklich zum Testen gewollt. Sie ermöglichen wiederholbare Persistenz- und Benutzerwechseltests und können für den aktuellen Zweck bestehen bleiben. Ein öffentliches Demokonto mit bewusst gemeinsamen Beispieldaten benötigt ebenfalls kein Versprechen individueller Vertraulichkeit.
Der technische Übergangspunkt bleibt relevant: `updateOrCreate` setzt beim erneuten Seeden auch Passwörter bestehender Konten zurück. Eine Beschränkung auf lokale oder ausdrücklich freigegebene Demo-Umgebungen fehlt. Das Protokoll dokumentiert den Seeder-Aufruf auch beim damaligen Live-Deployment; daraus folgt nicht, dass dort echte Kundenkonten existieren.
**Auswirkung im Testbetrieb:** Veränderungen gemeinsam verwendeter Testdaten und Passwort-Resets können Tests oder Vorführungen beeinflussen. Der ursprüngliche Hinweis auf den Zugriff auf private Kundeninhalte beschreibt ein späteres Nutzungsszenario, keinen festgestellten aktuellen Datenbestand.
**Maßnahme:** Die sechs Testkonten für die Persistenztests beibehalten; Seed-/Reset-Verhalten dokumentieren. Für eine öffentlich interaktive Demo entscheiden, ob Besucher gemeinsame Beispieldaten bearbeiten oder nur den lokalen Demo-Modus nutzen. Vor Einführung echter Konten Demo-Seeding ausdrücklich von deren Einrichtung und Daten trennen; bestehende individuelle Passwörter dann nicht durch einen allgemeinen Seeder überschreiben.
### A03 – API-Login ohne Rate-Limit
**Priorität:** P2 im begrenzten Testbetrieb; P1 vor öffentlichem API-Zugriff. **Nachweis:** Quellcode und registrierte Routen.
**Fundstellen:** [api.php](../backend/routes/api.php), Z. 11; [AuthController.php](../backend/app/Http/Controllers/Api/AuthController.php), Z. 16–30; [bootstrap/app.php](../backend/bootstrap/app.php), Middleware-Konfiguration.
`POST /api/login` prüft Zugangsdaten und erzeugt Tokens, ohne Fehlversuche zu begrenzen. Ein möglicher Schutz der separaten Web-Anmeldung greift für diesen eigenen API-Controller nicht. Auch für Uploads und Sync ist kein anwendungseigenes Throttling sichtbar. Ein vorgelagerter Infrastruktur-Schutz ist unbekannt.
**Auswirkung:** Wiederholte Passwortversuche sowie Last durch Passwort-Hash-Prüfungen und Token-Erzeugung. Bei bewusst bekannten Demo-Zugängen steht zunächst die Verfügbarkeit der öffentlich erreichbaren Demo im Vordergrund; für individuelle Konten kommt der Schutz vor Passwortversuchen hinzu.
**Maßnahme:** Login-Limiter pro normalisierter Identität und IP, ergänzend passende Limits für API und Uploads. Mit Tests für HTTP 429 und die Rücksetzung nach erfolgreichen/zeitlich abgelaufenen Versuchen absichern.
### A04 – Verspätete Antworten überschreiten den Benutzerkontext
**Priorität:** P1. **Nachweis:** Event-Store isoliert reproduziert; verwandtes Muster in Settings und Uploads durch Codeprüfung.
**Fundstellen:** [events.js](../frontend/src/stores/events.js), Z. 170–233, 308–325, 607–658 und 712–720; [settings.js](../frontend/src/stores/settings.js), Z. 225–252.
`loadRemoteEvents()` bindet die Anfrage nicht dauerhaft an den Benutzer, für den sie gestartet wurde. `normalizeRemoteEvent()` liest nach dem Warten die dann aktuelle `currentUserId`. Antworten schreiben ohne Prüfung einer Sitzungs-/Anfragegeneration in den gemeinsamen Store und Cache.
**Reproduktion:** Anfrage von A offen lassen, auf B wechseln, zuerst Bs Antwort und danach As Antwort auflösen. Ergebnis: aktiver Benutzer B, angezeigter Event `private A`, lokal mit `userId: B` versehen. Das ist ein Fehler im Frontend; die API musste dafür keinen fremden Zugriff erlauben.
`private A` war lediglich der künstliche Titel im isolierten Reproduktionstest, kein ausgelesener privater Kundeninhalt. Im aktuellen Testmodell verfälscht der Fehler die Zuordnung zwischen den sechs Testkonten und damit die Persistenztests; diese Bedeutung bleibt auch ohne echte Kundenkonten hoch.
Bei Settings kann die verspätete Antwort ebenfalls den aktuellen Zustand überschreiben. Bei Medien wird `editingEventId` nach asynchronen Schritten erneut gelesen; ein Panelwechsel während eines Uploads kann die Antwort dem falschen Event zuordnen.
**Maßnahme:** Benutzer-ID, Sitzungsgeneration und Event-ID beim Start festhalten; nach jedem relevanten `await` prüfen; alte Requests abbrechen oder deren Ergebnisse verwerfen. Dasselbe Prinzip für Cache-Schreibvorgänge und Settings anwenden. Regressionsfälle mit verzögerten Antworten aufnehmen.
### A05 – Ort und Bildtitel werden für Remote-Events nicht gespeichert
**Priorität:** P1. **Nachweis:** durchgängiger Abgleich von UI, Payload, Request, Model, Migration und Resource.
**Fundstellen:** [events.js](../frontend/src/stores/events.js), Z. 179–188, 197–207 und 527–537; [StoreEventRequest.php](../backend/app/Http/Requests/StoreEventRequest.php); [Event.php](../backend/app/Models/Event.php); [EventResource.php](../backend/app/Http/Resources/EventResource.php); [Event-Migration](../backend/database/migrations/2026_02_24_161710_create_events_table.php).
Das Panel bearbeitet `location` und `keyImageTitle`. `eventPayload()` überträgt beide nicht; im Backend fehlen Validierung, Spalten, Mapping und Antwortfelder. Beim Zusammenführen der Serverantwort setzt die Normalisierung sie wieder auf leere Werte. Das betrifft somit bereits bestätigte Speicherantworten, nicht erst einen späteren Gerätewechsel.
**Maßnahme:** Beide Felder über die gesamte Datenkette ergänzen und mit einem Create-/Update-/Reload-Roundtrip testen. Migration und API-Vertrag dokumentieren. Globale Farbverläufe liegen dagegen bereits in Settings; gleichnamige Event-Altlasten nicht ungeprüft zu neuen Pflichtfeldern machen.
### A06 – Automatisches Speichern verliert Änderungen beim Eventwechsel
**Priorität:** P1. **Nachweis:** isoliert mit dem tatsächlichen Store-Code reproduziert.
**Fundstellen:** [events.js](../frontend/src/stores/events.js), Z. 500–519 und 551–564; zusätzlich Z. 266–285 für überlappende PUT-Anfragen.
Die Formularwerte werden mit 300 ms Verzögerung gespeichert. `openPanel()` ersetzt die ausgewählte Event-ID und Formularwerte, ohne zuvor den ausstehenden Speicherauftrag des bisherigen Events abzuschließen. Der Timer arbeitet anschließend mit den neuen Werten.
**Reproduktion:** Event A öffnen, Titel ändern und innerhalb der Verzögerung Event B öffnen. Im kontrollierten Lauf wurde nur `/events/e2` mit Bs unverändertem Titel gespeichert; As Titel blieb `original A`.
Zusätzlich sind mehrere PUT-Anfragen desselben Events nicht serialisiert oder versioniert. Eine ältere Antwort kann den lokalen Zustand zurücksetzen; eine ältere, später verarbeitete Anfrage kann auch neuere Serverwerte überschreiben. Settings verwenden ebenfalls unkoordinierte vollständige PUT-Snapshots.
**Maßnahme:** Vor dem Eventwechsel den alten Entwurf mit seiner festen ID sichern. Pro Event eine geordnete Schreibfolge oder Revisionen verwenden und veraltete Antworten ignorieren. Für konkurrierende Geräte ein Konfliktverfahren vorsehen. Debounce allein garantiert keine Schreibreihenfolge.
### A07 – Speicherfehler bleiben unsichtbar und können Daten verlieren lassen
**Priorität:** P1. **Nachweis:** Codeprüfung der Fehler- und Wiederladepfade.
**Fundstellen:** [events.js](../frontend/src/stores/events.js), Z. 225–232, 252–257, 286–305, 338–369 und 594–604; [settings.js](../frontend/src/stores/settings.js), Z. 194–210; [EventPanel.vue](../frontend/src/components/EventPanel.vue), Z. 454–458 und 484–488; [quasar.config.js](../frontend/quasar.config.js), `framework.plugins`.
Remote-Änderungen werden zunächst lokal dargestellt. Fehler beim Erstellen/Ändern setzen teilweise nur `syncStatus: 'error'`; beim Löschen wird der Eintrag vor der Serverbestätigung entfernt und ein Fehler lediglich geloggt. Es gibt keine aktive Retry-Queue für diese Schreibvorgänge. Beim nächsten erfolgreichen Laden ersetzt der Serverstand den Cache: nie gespeicherte Änderungen verschwinden, fehlgeschlagene Löschungen tauchen wieder auf.
Ein fehlgeschlagenes initiales Remote-Laden ergibt eine leere Eventliste, obwohl ein Cache vorhanden sein kann. Die Oberfläche unterscheidet diesen Fehler nicht zuverlässig von einem leeren Konto. Upload-Fehlermeldungen verwenden `$q.notify?.(...)`, aber Quasar registriert nur `Dark`; die optionale Verkettung verschluckt das fehlende Notify-Plugin.
**Maßnahme:** Sichtbare Zustände für Laden, Speichern, gespeichert und fehlgeschlagen; Entwürfe und fehlgeschlagene Mutationen erhalten; Löschfehler zurückrollen oder als ausstehend anzeigen. Entweder einen vollständigen Offline-/Retry-Vertrag implementieren oder Offline-Schreiben ausdrücklich begrenzen. Notify korrekt registrieren und Validierungsfehler am betroffenen Feld anzeigen.
### A08 – Cursor-Paginierung kann Events mit gleichem Datum überspringen
**Priorität:** P1. **Nachweis:** Controller und Pagination-Implementierung geprüft; kein eigener Datenbank-Reproduktionstest hinzugefügt.
**Fundstelle:** [EventController.php](../backend/app/Http/Controllers/Api/EventController.php), Z. 19–33.
Die Abfrage sortiert ausschließlich nach `date`. Dieses Feld ist nicht eindeutig. Liegt eine Seitengrenze innerhalb mehrerer Events desselben Tages, springt der nächste Cursor zu einem späteren Datum; noch nicht gelieferte Events dieses Tages werden übersprungen. Bereits `limit=1` und zwei Events am selben Tag reichen als Regressionsszenario. Der aktive Client lädt zwar Folgeseiten, kann übersprungene Datensätze aber nicht wiederherstellen.
Laravel verlangt hierfür eine eindeutige Sortierspalte oder eindeutige Spaltenkombination. [Laravel-Dokumentation](https://laravel.com/docs/12.x/pagination#cursor-vs-offset-pagination)
**Maßnahme:** Stabil nach Datum und eindeutiger ID sortieren; über mehrere Seiten mit gleichen Datumswerten testen. `limit`, `since` und Cursor-Eingaben validieren; Filter beim Erzeugen der Folgelinks erhalten. Aktuell begrenzt `min(..., 200)` nur die Obergrenze und ersetzt keine Eingabevalidierung.
### A09 – Sync-Endpunkt umgeht die normalen Event-Validierungen
**Priorität:** P1. **Nachweis:** Codeprüfung; Endpunkt ist für angemeldete Benutzer erreichbar.
**Fundstelle:** [EventController.php](../backend/app/Http/Controllers/Api/EventController.php), Z. 127–193.
Validiert werden Action, UUID und der äußere Payload-Typ. Werte wie Titel, Datum, Emotion, URL und Notiz werden aus dem Payload direkt übernommen. Die Regeln von `StoreEventRequest` und `UpdateEventRequest` greifen hier nicht. Beispielsweise passiert `emotion: 2` die Sync-Validierung, obwohl normales CRUD nur den Bereich −1 bis 1 erlaubt. Fehler werden mit dem unveränderten Exception-Text an den Client zurückgegeben; darunter können SQL- und Schema-Details sein. Ein Update eines nicht existierenden Events wird außerdem als `ok` quittiert.
**Maßnahme:** Gemeinsame Feldregeln für CRUD und Sync, aktionsabhängige Payload-Validierung und klar definierte Ergebnisse für fehlende Events. Technische Exception-Details serverseitig protokollieren, nach außen stabile Fehlercodes liefern. Eigentümerfilter beibehalten: Eine direkte Änderung fremder Events wurde in diesem Controller nicht festgestellt.
## 5. Weitere Fehler und Stabilitätsrisiken
### A10 – Bildcache ist nicht nach Benutzer getrennt
**Priorität:** P2; relevant auf gemeinsam verwendeten Browserprofilen. **Nachweis:** Codeprüfung.
**Fundstellen:** [useImageCache.js](../frontend/src/composables/useImageCache.js), Z. 9, 86–95 und 166–177; [db/index.js](../frontend/src/db/index.js), `imageCache`; [auth.js](../frontend/src/stores/auth.js), Z. 144–153.
Memory- und IndexedDB-Cache verwenden die Bild-URL als Schlüssel. Cachetreffer benötigen weder aktuellen Token noch passenden Benutzer. Beim Abmelden werden die Bildcaches nicht geleert. Settings-Hintergrund-URLs enthalten keine Benutzer-ID, sondern nur einen optionalen Zeitparameter. Event-Medien haben unterschiedliche IDs, bleiben aber unter ihrer alten URL lokal abrufbar. Das ist eine lokale Datenschutzlücke, kein nachgewiesener fehlender Ownership-Check des Servers.
**Maßnahme:** API-Origin, Benutzer-ID, Medien-ID/URL und Bildvariante in Cache-Schlüssel aufnehmen; Caches beim Benutzerwechsel kontrolliert verwerfen. Blob-URLs freigeben und ein Größenlimit vorsehen. `clearEventImages()` existiert, wird vom aktiven Anwendungscode aber nicht aufgerufen; vollständig geladene Bilder sind zudem nicht durchgängig im IndexedDB-Index für diese Bereinigung enthalten.
### A11 – Abgelaufener Token und Frontend-Anmeldestatus laufen auseinander
**Priorität:** P2. **Nachweis:** Codeprüfung.
**Fundstellen:** [apiClient.js](../frontend/src/services/apiClient.js), Z. 105–107; [auth.js](../frontend/src/stores/auth.js), Z. 67–90 und 144–153; [router/index.js](../frontend/src/router/index.js), Router-Guard.
Bei HTTP 401 wird der Token entfernt, das Benutzerprofil in localStorage und der Auth-Store bleiben jedoch angemeldet. Der Router leitet einen Besuch von `/login` dann erneut zur App um. Die API ist weiterhin geschützt, aber die UI kann in einem nicht mehr speicherfähigen Zustand hängen bleiben.
Logout wartet außerdem nicht auf die Token-Widerruf-Anfrage und ignoriert Fehler. Die bestehende Logout-Prüfung mit `Passport::actingAs` bestätigt HTTP 204, nicht den vollständigen Lebenszyklus eines zuvor real ausgestellten Tokens.
**Maßnahme:** Zentrale sitzungsgebundene 401-Behandlung für Token, Profil, Requests und Navigation; keine neue Sitzung wegen einer verspäteten 401-Antwort der alten Sitzung löschen. Widerruf mit festgehaltenem Token ausführen und Token-Laufzeiten festlegen. Test: Login → echter Tokenzugriff → Logout → derselbe Token wird abgewiesen.
### A12 – Älterer Synchronisationsservice ist unvollständig und nicht benutzergebunden
**Priorität:** P2; vor einer Aktivierung als Offline-Lösung zwingend bearbeiten. **Nachweis:** Codeprüfung einschließlich tatsächlicher Aufrufer.
**Fundstellen:** [syncService.js](../frontend/src/services/syncService.js), Z. 10–12, 31–42, 77–79 und 99–161; [events.js](../frontend/src/stores/events.js), Z. 349–369.
Konkrete Probleme:
- Die gesamte Queue wird ohne `userId`-Filter mit dem aktuell gespeicherten Token gesendet. Bei vorhandenen Altaufträgen können so Daten im falschen Konto angelegt werden.
- `lastSyncCursor` ist global; neue eingehende Events werden ohne `userId` gespeichert.
- Es wird nur eine Seite abgeholt, anschließend aber bereits die lokale aktuelle Uhrzeit als Sync-Marke gespeichert. Weitere Seiten und Änderungen während des Abrufs können verloren gehen.
- Löschungen besitzen keine Tombstones und werden deshalb beim Delta-Abruf nicht übertragen.
- Die Rekursion für den nächsten 100er-Batch endet sofort am noch gesetzten `isSyncing`-Flag.
- Erfolgreiches Remote-Laden kehrt in `init()` vor `startAutoSync()` zurück. Unabhängig davon startet der beim Import registrierte `online`-Listener die Queue-Verarbeitung bei einem Reconnect.
Neue Demo-Daten erzeugen aktuell keine Queue-Einträge. Deshalb wäre die Aussage „alle Demo-Daten werden hochgeladen“ falsch. Das Risiko betrifft insbesondere Altbestände und eine spätere Nutzung dieses Services.
**Maßnahme:** Einen verbindlichen Persistenzweg festlegen. Bis zur Fertigstellung alte Queue-Verarbeitung kontrolliert deaktivieren oder strikt pro Benutzer betreiben. Danach vollständige Pagination, serverseitige Sync-Marken, Löschmarker, Retry und Konfliktbehandlung ergänzen.
### A13 – Event-Löschung hinterlässt Bilddateien
**Priorität:** P2. **Nachweis:** Löschpfade, Modelle und Fremdschlüssel geprüft.
**Fundstellen:** [EventController.php](../backend/app/Http/Controllers/Api/EventController.php), Z. 113–121 und 188–190; [Event-Medien-Migration](../backend/database/migrations/2026_06_03_131801_create_event_media_table.php); [Event.php](../backend/app/Models/Event.php).
Das Löschen eines Events entfernt über `cascadeOnDelete` dessen Medien-Datensätze, aber keine Dateien. Die Datei-Löschlogik liegt ausschließlich im Mediencontroller und wird beim Löschen des gesamten Events nicht aufgerufen. Die Models enthalten keinen entsprechenden Cleanup. Dasselbe Grundproblem besteht beim kaskadierenden Löschen eines Benutzers; Settings-Hintergründe benötigen ebenfalls einen eigenen Löschpfad.
**Auswirkung:** Private Dateien verbleiben ohne Datensatz auf dem Datenträger, der Speicherverbrauch wächst. Daraus folgt keine automatische öffentliche Abrufbarkeit, aber die erwartete Datenlöschung ist unvollständig.
**Maßnahme:** Gemeinsamen Löschservice bzw. zuverlässigen Cleanup-Job für alle Löschwege entwickeln. Dateipfade vor der Datenbanklöschung erfassen; ein reiner Model-Observer reicht für Query-Builder-Massenlöschungen und Datenbank-Kaskaden nicht. Löschung aller drei Varianten testen.
### A14 – Medienersatz kann bei Teilfehlern alte und neue Bilder verlieren
**Priorität:** P2. **Nachweis:** Codeprüfung; Speicherfehler wurden nicht künstlich ausgelöst.
**Fundstellen:** [EventMediaController.php](../backend/app/Http/Controllers/Api/EventMediaController.php), Z. 39–75; [SettingsMediaController.php](../backend/app/Http/Controllers/Api/SettingsMediaController.php), `store()`; [filesystems.php](../backend/config/filesystems.php), lokaler Datenträger.
Datei-Schreibergebnisse werden nicht geprüft, während der Datenträger `throw: false` verwendet. Beim Key-Image-Ersatz werden bisherige Bilder gelöscht, bevor der neue Datensatz vollständig gespeichert ist. Ein voller Datenträger oder ein Datenbankfehler kann dadurch zu einer positiven Antwort mit fehlenden Dateien, verlorenen bisherigen Bildern oder verwaisten neuen Dateien führen. Parallele Key-Image-Uploads sind ebenfalls nicht als eine Operation geschützt.
**Maßnahme:** Schreibfehler explizit behandeln, neue Dateien zunächst vollständig bereitstellen, Metadaten konsistent umschalten und alte Dateien erst danach entfernen. Fehler mit kompensierender Bereinigung behandeln. Eine Datenbanktransaktion allein macht Dateisystemoperationen nicht atomar.
### A15 – Bildgröße wird erst nach vollständigem Dekodieren reduziert
**Priorität:** P2. **Nachweis:** Request-Regeln und GD-Verarbeitung geprüft.
**Fundstellen:** [StoreEventMediaRequest.php](../backend/app/Http/Requests/StoreEventMediaRequest.php); [StoreSettingsMediaRequest.php](../backend/app/Http/Requests/StoreSettingsMediaRequest.php); [EventMediaImageProcessor.php](../backend/app/Services/EventMediaImageProcessor.php), `process()`; [SettingsMediaController.php](../backend/app/Http/Controllers/Api/SettingsMediaController.php), `processBackground()`.
Das Limit von 10 MB begrenzt die komprimierte Datei, nicht ihre Pixelzahl. `imagecreatefromstring()` dekodiert das Bild vollständig, bevor es verkleinert wird. Große stark komprimierte Bilder können deshalb viel Speicher und CPU verbrauchen. Pro Benutzer/Event existieren außerdem keine erkennbaren Medienquoten.
**Maßnahme:** Maximale Dimensionen und Pixelzahl vor dem teuren Verarbeitungsschritt prüfen, Upload-Rate und Speichermengen begrenzen. Bei wachsendem Volumen Bildverarbeitung in begrenzte Worker auslagern. Fehler kontrolliert zurückmelden; die vorhandene Prüfung von Dateitypen beibehalten.
### A16 – Settings werden strukturell zu wenig validiert
**Priorität:** P2. **Nachweis:** Request und unmittelbare Verbraucher geprüft.
**Fundstellen:** [UpdateSettingsRequest.php](../backend/app/Http/Requests/UpdateSettingsRequest.php), Z. 24–39; [settings.js](../frontend/src/stores/settings.js), Z. 127 und 245–249.
`floatingLines` und `presets` werden nur als Arrays validiert. Für Elemente, Schlüssel, Anzahlen, Farben und Zahlenbereiche fehlen überwiegend Regeln. Beispielsweise kann `presets: [null]` die äußere Array-Regel erfüllen, während das Frontend anschließend `preset.id` liest. Unbekannte Schlüssel im Gesamt-Settings-Array können ebenfalls in die gespeicherten Daten gelangen. Das eröffnet in erster Linie Fehlzustände des eigenen Kontos und Ressourcenverbrauch, keine bestätigte Fremddatenänderung.
**Maßnahme:** Versioniertes Settings-Schema mit erlaubten Schlüsseln, Elementvalidierung, Grenzwerten und Größenlimit einführen. Geladene Cache- und API-Daten zusätzlich defensiv normalisieren. [Laravel-Hinweis zu Array-Validierung](https://laravel.com/docs/12.x/validation#validating-arrays)
### A17 – Laravel-Profilseite liefert HTTP 500
**Priorität:** P2. **Nachweis:** vorhandener Feature-Test zweimal fehlgeschlagen.
**Fundstellen:** [ProfileUpdateTest.php](../backend/tests/Feature/Settings/ProfileUpdateTest.php), Z. 6–10; [web.php](../backend/routes/web.php), Z. 17; [Profil-Komponente](../backend/resources/views/livewire/settings/profile.blade.php); [vorhandenes Layout](../backend/resources/views/components/layouts/app.blade.php).
`profile page is displayed` erwartet HTTP 200 und erhält HTTP 500. Die konkrete Exception lautet `No hint path defined for [layouts]`. Der installierte Livewire-Stand erwartet standardmäßig `layouts::app`; das vorhandene App-Layout liegt unter `components/layouts/app.blade.php`, eine passende Anwendungskonfiguration ist nicht vorhanden.
**Maßnahme:** Layout-Zuordnung für den verwendeten Livewire-/Volt-Stand explizit und konsistent konfigurieren. Die Profilseite und die benachbarten Settings-Seiten als vollständige HTTP-Seiten prüfen. Erfolgreiche komponenteninterne Methoden-Tests beweisen nicht, dass die Seite gerendert werden kann.
### A18 – Kalenderdaten werden in westlichen Zeitzonen verschoben
**Priorität:** P2. **Nachweis:** Datumsausdruck unter `America/Los_Angeles` ausgeführt.
**Fundstellen:** [TimelineView.vue](../frontend/src/components/TimelineView.vue), Z. 129–155; [EventPanel.vue](../frontend/src/components/EventPanel.vue), Z. 336–339.
`new Date('YYYY-MM-DD')` wird anschließend mit lokalen Monats-/Jahres-/Datumsfunktionen dargestellt. Dadurch wird `2026-01-01` in der überprüften Zeitzone zu `31.12.2025`. Neben dem Eventdatum können somit auch Monats- und Jahreszuordnungen der Timeline falsch sein.
**Maßnahme:** Reine Kalenderdaten als solche behandeln: Datumsteile ohne UTC-Zeitpunkt parsen oder UTC-Auswertung und Darstellung konsequent vereinheitlichen. Mindestens UTC, Europa/Berlin und Amerika/Los_Angeles testen.
### A19 – Zulässige URL-Länge passt nicht zur MySQL-Spalte
**Priorität:** P2. **Nachweis:** Schema-/Request-Abgleich; nicht gegen MySQL ausgeführt.
**Fundstellen:** [StoreEventRequest.php](../backend/app/Http/Requests/StoreEventRequest.php) und [UpdateEventRequest.php](../backend/app/Http/Requests/UpdateEventRequest.php), Regel `image`; [Event-Migration](../backend/database/migrations/2026_02_24_161710_create_events_table.php), Spalte `image`.
Die API erlaubt 500 Zeichen, die Migration erzeugt eine normale String-Spalte mit standardmäßig 255 Zeichen. Unter MySQL kann eine formal gültige Anfrage mit längerer URL deshalb beim Speichern scheitern. SQLite-Tests decken diese Längenbeschränkung nicht gleichwertig ab. Die eigenen kurzen Medienpfade erreichen das Limit normalerweise nicht, externe URLs können es erreichen.
**Maßnahme:** Vertrag und Spaltenlänge angleichen oder externe Bild-URLs bewusst einschränken; Grenzwertprüfung zusätzlich unter MySQL ausführen.
## 6. Optimierungen und Wartbarkeit
### O01 – Datenladen und Bildspeicher begrenzen
Der aktive Event-Store lädt sämtliche Seiten einschließlich Medienmetadaten, bevor der endgültige Zustand gesetzt wird. Die DOM-Virtualisierung reduziert den Renderaufwand, aber weder Gesamttransfer noch Cache-Schreibmenge. Für große Timelines bieten sich seitenweises Anzeigen und bei Bedarf geladene Galerie-Metadaten an. Vorher A08 beheben, damit Datensätze nicht bereits an Seitengrenzen fehlen.
Medien werden serverseitig über `Storage::get()` vollständig in den PHP-Speicher geladen. Bei höherem Volumen Streaming bzw. autorisierte Webserver-Auslieferung und kontrolliertes HTTP-Caching prüfen. Der clientseitige Bildcache braucht ein Budget und eine Eviction-Strategie; gleiche parallele Bildanfragen lassen sich außerdem zusammenfassen.
### O02 – Bundle und öffentliche Assets verkleinern
Der gemessene `LifeWaveLayout`-Chunk hat **714,12 kB minifiziert / 195,67 kB gzip**. `frontend/public/images` belegt rund **59 MiB**, das erzeugte SPA-Verzeichnis rund **106 MiB**. Die Verzeichnisgröße bedeutet nicht, dass beim Start alles übertragen wird; Quasar kopiert aber auch ungenutzte öffentliche Assets in das Build-Ergebnis.
Große Beispielbilder verkleinern, ungenutzte Assets aus dem Produktionspfad entfernen und selten geöffnete Panels bei Bedarf laden. Three.js-/Shader-Anteil gezielt mit einem Bundle-Report prüfen. Die existierende Timeline-Virtualisierung, Begrenzung auf 16 Shaderpunkte, adaptive Pixelauflösung und Freigabe der WebGL-Ressourcen sind positive Ansätze und sollten erhalten bleiben.
### O03 – Settings-Schreiblast reduzieren
Änderungen von Scrollposition oder Darstellung lösen nach kurzer Verzögerung PUTs des gesamten Settings-Dokuments einschließlich Presets aus. Bei vielen Presets entsteht unnötiger Transfer und konkurrierende Snapshots erhöhen das Überschreibungsrisiko.
Persistente Benutzerpräferenzen von flüchtigem Viewport-Zustand trennen, nur geänderte Bereiche übertragen oder die Schreibfrequenz begrenzen. Beim lokalen Demo-Modus kann ein großes Base64-Hintergrundbild zusätzlich die localStorage-Quota erreichen; `localStorage.setItem()` in `persist()` behandelt diesen Fehler nicht. Größere Bilder gehören in den Blob-Speicher.
### O04 – Reproduzierbare Entwicklung und automatische Prüfgates
`frontend/package.json` führt vor jedem Dev-Start `npm install` aus; Compose startet ebenfalls `npm install` vor `npm run dev`. Damit wird beim Containerstart zweimal installiert. Installation und Start trennen, für kontrollierte Builds den vorhandenen Lockfile mit `npm ci` verwenden.
Im Repository wurde keine GitHub-Actions-Prüfpipeline unter `.github/workflows` gefunden. Lint, beide Builds, Backend-Tests und Paketprüfungen sollten nachvollziehbare CI-Gates erhalten. Frontend-Tests für Auth-/Store-Abläufe fehlen besonders; `npm test` ist nur ein Platzhalter. MySQL-Prüfungen ergänzen die vorhandenen schnellen SQLite-Tests.
Die generierten Buildwarnungen zu veralteten Browserslist-Daten und npm-Konfigurationsschlüsseln sind Wartungsthemen. Sie verursachten in dieser Prüfung keinen Buildabbruch.
### O05 – Prototypen und Statusdokumentation eindeutig zuordnen
`src`, `_src`, `legacy` und die Visualisierungsexperimente sollten klar als aktiv, archiviert oder experimentell markiert sein. Änderungen am falschen Baum bleiben sonst wirkungslos. Das Root-README enthält weiterhin Platzhalter; Setup, externe Infrastruktur, Demo-/Remote-Verhalten und Versionsstand sollten aus dem aktuellen Code beschrieben werden.
Größere Stores und das Event-Panel bündeln UI, Entwurf, API und Cache. Nach Absicherung der kritischen Abläufe lohnt sich eine Aufteilung in klar abgegrenzte Verantwortlichkeiten. Ein großer Umbau vor Regressionstests würde die bereits vorhandenen Ablaufprobleme schwerer überprüfbar machen.
## 7. Was bereits gut gelöst ist
- Geschützte API-Routen verwenden `auth:api`; Event-Abfragen werden über die Benutzerrelation eingeschränkt.
- Direkte Medienauslieferung prüft den Besitzer; Settings-Hintergrundpfade werden aus dem authentifizierten Benutzer abgeleitet.
- Fremdzugriffe auf Events, Settings und Medien sind teilweise ausdrücklich durch Feature-Tests abgedeckt.
- Normales Event-CRUD verwendet Form Requests und explizites Feldmapping; dort wird kein beliebiges Request-Objekt massenhaft übernommen.
- Private Medien liegen auf dem privaten Datenträger; Dateipfade werden serverseitig erzeugt, Bildtypen eingeschränkt und Bilder neu kodiert.
- Medien werden beim Event-Laden eager geladen; die offensichtliche N+1-Abfrage pro Event wird damit vermieden.
- Stabile Client-UUIDs, Indizes für Benutzer/Datum und Benutzer/Änderungszeit sind eine passende Grundlage für weitere Synchronisationsarbeit.
- CORS enthält konkrete erlaubte Origins. Fehlendes Login-Throttling wird dadurch allerdings nicht ersetzt.
- Im aktiven Frontend wurde bei der gezielten Suche keine Verwendung von `v-html`, `innerHTML` oder `eval()` gefunden. Das ist keine vollständige XSS-Freigabe, aber ein positiver Ausgangspunkt.
## 8. Empfohlene Reihenfolge
### 8.1 Jetzt: bestehende Testversion zu einer verlässlichen Demo entwickeln
1. **Demo-Kernabläufe reparieren:** A04–A08. Kontenwechsel, vollständige Feldspeicherung, zuverlässiges Autosave, sichtbare Fehler und vollständiges Wiederladen haben Vorrang.
2. **Vorführung mit reproduzierbaren Daten vorbereiten:** Die sechs API-Konten für Persistenztests und ausgewählte Beispielgeschichten verwenden. Den lokalen Demo-Modus separat testen. Festlegen, wie ein definierter Präsentationsstand wiederhergestellt wird, ohne laufende Tests unbeabsichtigt zurückzusetzen.
3. **Betroffene Nebenabläufe stabilisieren:** A09–A19 nach tatsächlicher Demo-Nutzung abarbeiten. Besonders relevant sind Anmeldung nach Tokenablauf, Uploads, Medienersatz und Löschung. A17 blockiert eine Vorführung der Laravel-Profilseite; wenn nur die Quasar-Oberfläche gezeigt wird, ist dieser Fehler kein unmittelbarer Hindernisgrund für genau diese Präsentation.
4. **Gezielt absichern:** Tests für die nachgewiesenen Store-Fehler ergänzen und die gezeigten Abläufe auf den vorgesehenen Browsern/Mobilgeräten durchspielen. Eine vollständige Offline-Synchronisation ist für eine bewusst online genutzte Werbedemo nicht erforderlich; Fehler und Offline-Grenzen müssen trotzdem verständlich bleiben.
### 8.2 Vor öffentlicher, interaktiver Nutzung
Zugangsmodell festlegen: betreute Präsentation, lokal ausprobierbare Demo oder gemeinsame serverseitige Testkonten. Werden API-Schreibzugriffe oder Uploads öffentlich angeboten, A01/A03 sowie die Validierungs- und Ressourcenbegrenzungen aus A09/A15/A16 vor dieser Freigabe bearbeiten. Für gemeinsame Konten sind Veränderungen durch andere Besucher erwartbares Verhalten; eine Kennzeichnung als gemeinsame Beispieldaten und ein kontrollierter Reset helfen der Vorführbarkeit.
Falls API oder Entwicklungsdienste bereits öffentlich erreichbar sind, ist dieser Teil entsprechend vorgezogen. Der Status der Erreichbarkeit ist im Audit offen.
### 8.3 Vor echten individuellen Konten
Demo-Seeding gemäß A02 trennen, Benutzer-/Cache-/Token-Isolation vollständig absichern und zuverlässige Speicherung sowie Medienlöschung verbindlich machen. Weitergehende Kontenverwaltung und Betriebsanforderungen an dieser Phase ausrichten. Die mögliche spätere Crowdfunding-Kampagne ändert an der technischen Reihenfolge für die aktuelle Demo zunächst nichts.
### Abnahmekriterien für die Demo-Stabilisierung
- Testkonto A laden, währenddessen zu B wechseln: keine Daten, Bilder oder Settings von A im Kontext B; auch bei verspäteter 401-Antwort bleibt B korrekt angemeldet.
- Event A ändern und unmittelbar B öffnen: As Änderung bleibt nach Reload erhalten.
- Zwei Schreibanfragen absichtlich verzögern/umordnen: der jüngere Stand gewinnt kontrolliert.
- Ort und Bildtitel nach Create, Update, Reload und erneutem Login unverändert vorhanden.
- Mehrere Events mit identischem Datum über mehrere Cursorseiten vollständig und ohne Dubletten geladen.
- Netzwerk-/422-/500-Fehler beim Schreiben sichtbar; keine still verworfenen Entwürfe oder bestätigten Löschungen ohne Servererfolg.
- Lokalen Demo-Modus separat prüfen: Anlegen und Reload funktionieren browserlokal; neue Demo-Mutationen gehen nicht an die API.
- Definierte Beispieldaten lassen sich für die Vorführung nachvollziehbar herstellen; persistierte Testdaten bleiben zwischen normalen Sitzungen erhalten.
- Frontend-Build/Lint und die Tests der kritischen Abläufe erfolgreich; betroffene Vorführpfade im Browser geprüft.
### Ergänzende Kriterien für öffentliche API-Nutzung und späteren Echtbetrieb
- Ungültige Sync-Payloads werden abgewiesen; technische Exception-Texte erscheinen nicht in API-Antworten.
- Event-/Benutzerlöschung beseitigt zugehörige Dateien; fehlgeschlagener Bildersatz erhält das bisherige Bild.
- API-Login wird bei wiederholten Fehlversuchen begrenzt; reales Token ist nach erfolgreichem Logout unbrauchbar.
- Öffentlich nutzbare Uploads haben angemessene Mengen-/Größenlimits; relevante Sicherheitsupdates sind geprüft.
- Vor echten Konten: kein allgemeiner Seeder setzt deren individuelle Passwörter zurück oder vermischt ihre Daten mit Präsentationsdaten.
- Gesamte Backend-Suite grün; MySQL-Integration und automatische Prüfungen ergänzen die Demo-Tests.
## 9. Paketprüfung: kompakte Bestandsaufnahme
Die folgenden Zahlen dokumentieren den Registry-Stand vom 17.09.2026 und dienen der Nachverfolgung. npm zählt betroffene Pakete einschließlich transitiv betroffener Pakete; Composer zählt hier Advisory-Einträge. Die Zahlen dürfen daher nicht direkt addiert oder als Anzahl ausnutzbarer Anwendungslücken interpretiert werden.
| Composer-Paket | Meldungseinträge |
| --- | ---: |
| guzzlehttp/guzzle | 9 |
| guzzlehttp/psr7 | 4 |
| laravel/framework | 3 |
| laravel/passport | 1 |
| league/commonmark | 12 |
| livewire/livewire | 1 |
| phpseclib/phpseclib | 4 |
| symfony/http-foundation | 1 |
| symfony/http-kernel | 1 |
| symfony/mailer | 1 |
| symfony/mime | 2 |
| symfony/polyfill-intl-idn | 1 |
| symfony/routing | 2 |
| symfony/yaml | 3 |
Frontend-npm meldet: `@humanfs/node`, `@quasar/app-vite`, `baseline-browser-mapping`, `body-parser`, `brace-expansion`, `browserslist`, `esbuild`, `flatted`, `immutable`, `js-yaml`, `lodash`, `nanoid`, `node-forge`, `path-to-regexp`, `picomatch`, `postcss`, `postcss-selector-parser`, `qs`, `quasar`, `serialize-javascript`, `vite`.
Backend-npm meldet: `axios`, `browserslist`, `follow-redirects`, `form-data`, `lodash`, `nanoid`, `picomatch`, `postcss`, `rollup`, `shell-quote`, `vite`.
Reproduzierbare Basisbefehle: `npm audit --json` jeweils in `frontend/` und `backend/`, sowie `composer audit --locked --format=json` in `backend/`. Nach Updates die dann aktuellen Einzelmeldungen und ihre tatsächlichen Ausführungspfade erneut bewerten.

View file

@ -0,0 +1,341 @@
# Frontend: Vorschläge für UI, Nutzererlebnis und Werbedemo
**Stand:** 17.09.2026, aktueller Arbeitsstand einschließlich der bereits vorgenommenen Änderungen.
**Ziel:** Eine verständliche, überzeugende und flüssig bedienbare Demo, die Produktinteresse weckt.
**Umfang:** Darstellung, Bedienung, Texte, wahrgenommene Performance und sinnvolle Produkterweiterungen. Keine erneute Sicherheitsprüfung und keine Implementierung.
## 1. Einschätzung und Grundlage
Die LifeWave-Darstellung ist das stärkste visuelle Merkmal des Produkts: Eine persönliche Geschichte wird als Folge von Momenten und emotionalen Höhen und Tiefen sichtbar. Diese Eigenständigkeit sollte erhalten bleiben. Den größten Nutzen sehe ich jetzt in einem besseren Einstieg, verständlicher Orientierung und gut lesbaren Inhalten.
Für die Werbedemo sollte ein neuer Besucher ohne Erklärung durch den Entwickler verstehen: **Was sehe ich? Was kann ich hier tun? Warum möchte ich das später selbst nutzen?** Mehr Gestaltungsregler allein beantworten diese Fragen nicht.
Berücksichtigt sind das aktuelle Testmodell mit sechs serverseitig gespeicherten Testkonten und dem lokalen Demo-Zugang sowie das Ziel, über Social Media Interesse aufzubauen und eventuell später Crowdfunding zu starten. Die Testkonten bleiben für die Entwicklung sinnvoll.
### Was betrachtet wurde
- Aktive Loginseite, LifeWave-Layout, Timeline, Ereignispunkte, Event-Panel, Gestaltungsbereich und Benutzermenü.
- Browseransichten in Chromium bei Desktopgröße 1440 × 900, schmaler Ansicht und mobiler Emulation mit 390 × 844 CSS-Pixeln.
- Einstieg in eine frische lokale Demo, Anlegen/Bearbeiten eines Moments, sichtbarer Speicherstatus, Timeline mit eigens erzeugten fiktiven lokalen Beispielen sowie Menü- und Gestaltungspanels.
- Ergänzend die aktuellen UI-Texte und vorgesehenen Interaktionen in den aktiven Frontend-Dateien. Allgemeine App-Einstellungen und Galerieabläufe wurden ergänzend anhand der Komponenten betrachtet; die Browserdurchsicht war kein vollständiger E2E-Test.
Die Browserdaten lagen in isolierten Testprofilen. Es wurden keine API-Testkonten verändert und keine Anwendungscode-Änderungen vorgenommen. Einige automatisierte Navigationsversuche scheiterten an Overlay-/Übergangszuständen; daraus wird hier kein eigenständiger bestätigter Produktfehler abgeleitet.
**Grenzen:** Keine Messung auf echter Smartphone-Hardware, keine verlässliche FPS-/Ladezeitmessung und kein vollständiger Barrierefreiheitstest. Performancewerte weiter unten sind vorgeschlagene Ziele, keine gemessenen Ergebnisse. Die mobile Emulation ersetzt insbesondere keinen Test mit echter Bildschirmtastatur oder Safari.
### Bereits vorhanden und beizubehalten
- Sichtbare Zustände „Wird gespeichert …“, „Gespeichert“ und „Nicht gespeichert“ im Event-Panel; „Gespeichert“ wurde beim lokalen Bearbeiten im Browser gesehen.
- Benachrichtigungen für Lade-/Speicherfehler im aktuellen Layout.
- Tastaturaktivierung und Beschriftung der Ereignispunkte.
- Timeline-Virtualisierung, adaptive Darstellungsqualität und Pausieren der WebGL-Darstellung bei ausgeblendetem Tab.
- Verschiebbare Panels, Bildgalerie und Speichern eigener Gestaltungsvorlagen.
Diese Punkte werden nachfolgend gezielt weitergedacht. Die Liste unterstellt nicht, dass sie noch vollständig fehlen.
## 2. Prioritäten auf einen Blick
**A – vor einer selbstständig nutzbaren Werbedemo empfohlen:** Hürden, die das Verstehen oder die Kernbedienung unmittelbar beeinträchtigen.
**B – sinnvoller nächster Ausbau:** verbessert Komfort, Qualität oder Verlässlichkeit.
**C – später validieren:** Erweiterungsideen, deren Nutzen zunächst mit Interessenten geprüft werden sollte.
„Aufwand“ ist eine grobe relative Einschätzung des Umfangs einschließlich Gestaltung und Abstimmung, keine Zeitschätzung. Abhängigkeiten von Backend, Inhalten oder einem externen Dienst sind ausdrücklich vermerkt.
| ID | Vorschlag | Priorität | Nutzen | Aufwand |
| --- | --- | --- | --- | --- |
| V01 | Direkter Demo-Einstieg mit klarem Produktversprechen | A | Weniger Einstiegshürden | Klein–mittel |
| V02 | Kuratierte Beispielgeschichte zum Erkunden | A | Produktnutzen sofort erlebbar | Mittel |
| V03 | Hilfreicher Leerzustand und kurze Einführung | A | Erster Schritt wird verständlich | Klein |
| V04 | Einheitliche Sprache und Markenbezeichnung | A | Geschlossener, glaubwürdiger Eindruck | Klein; echte Zweisprachigkeit größer |
| V05 | Menüs auf wirksame Aktionen reduzieren | A | Keine Sackgassen | Klein |
| V06 | Kontrast und visuelle Hierarchie verbessern | A | Aktionen sind leichter erkennbar | Klein–mittel |
| V07 | Lesbare Ereignistitel und bessere Punkte ohne Bild | A | Inhalte erhalten mehr Gewicht | Mittel |
| V08 | Zeit- und Emotionsdarstellung verständlich erklären | A | Weniger Fehlinterpretationen | Klein |
| V09 | Direktnavigation und ergänzende Listenansicht | B | Momente schneller wiederfinden | Mittel–groß |
| V10 | Ereignisansicht für Lesen und Bearbeiten gestalten | B | Ruhigeres Erlebnis, bessere Desktopnutzung | Mittel |
| V11 | Panels eindeutig schließen und per Tastatur bedienen | A | Sichere Orientierung | Klein–mittel |
| V12 | Touchbedienung, Vergrößerung und mobile Ränder verbessern | A | Einfacher auf kleinen Bildschirmen | Mittel |
| V13 | Anlegen und Löschen nachvollziehbar machen | A | Weniger versehentliche Änderungen | Mittel |
| V14 | Speicher- und Verbindungsstatus weiterführen | B | Nutzer wissen, wo ihre Änderungen stehen | Mittel |
| V15 | Bildauswahl und Galerie mit Rückmeldungen ergänzen | B | Uploads und Bildausschnitte verständlich | Mittel |
| V16 | Gestaltungsvorlagen vor Detailreglern anbieten | B | Schnelle schöne Ergebnisse | Mittel |
| V17 | Ruhigen Modus und sparsame Darstellung anbieten | A | Zugänglichkeit und mobile Nutzbarkeit | Mittel |
| V18 | Ersten sichtbaren Inhalt beim Laden priorisieren | B | Schnellere wahrgenommene Reaktion | Mittel |
| V19 | Performance nach konkreten Nutzeraktionen beurteilen | B | Optimierungen werden überprüfbar | Klein–mittel |
| V20 | Passenden nächsten Schritt für Interessenten anbieten | A vor Bewerbung | Aus Interesse wird ein Kontakt | Klein–mittel, ggf. Dienst nötig |
| V21 | Kurzes Feedback direkt aus der Demo ermöglichen | B | Produktentscheidungen besser begründen | Klein–mittel, Empfänger nötig |
| V22 | Präsentationsmodus und ausgewählte Ausgabeformate | B/C | Bessere Vorführungen und Social-Media-Inhalte | Mittel–groß |
| V23 | Persönliche Kapitel und Kategorien erproben | C | Größere Geschichten strukturieren | Groß, Datenmodell betroffen |
| V24 | Demo verständlich und kontrolliert zurücksetzen | B | Wiederholbare Tests und Präsentationen | Mittel, bei Remote-Daten Backend nötig |
## 3. Einstieg und Verständlichkeit
### V01 – Direkter Demo-Einstieg mit klarem Produktversprechen
**Beobachtung:** Neue Besucher landen auf „Login“, lesen „Welcome back“ und sehen Kontenauswahl, Passwort sowie „Remember me“. Das entspricht einem Testzugang, erklärt aber den Nutzen für Erstbesucher kaum. Selbst beim lokalen Zugang bleibt das deaktivierte Passwortfeld sichtbar.
**Vorschlag:** Eine kurze Einstiegsfläche mit einem Satz wie „Deine wichtigen Momente als lebendige Lebenslinie“ und zwei klaren Wegen: **„Beispiel ansehen“** und **„Selbst ausprobieren“**. Die sechs Testkonten bleiben über einen separaten Bereich „Testzugänge“ erreichbar. Für den lokalen Einstieg genügt eine Aktion ohne Passwortmaske.
**Wirkung:** Das Produkt wird vor der Kontenauswahl verständlich. Der Hinweis „Deine Eingaben bleiben in diesem Browser“ erläutert den lokalen Modus in Alltagssprache.
**Prüfkriterium:** Eine neue Person kann erklären, was sie erwartet, und ohne Hilfe eine passende Demo-Ansicht öffnen.
### V02 – Eine kurze Beispielgeschichte zum Erkunden
**Beobachtung:** Der lokale Demo-Zugang startet absichtlich leer. Damit ist die zentrale Visualisierung zunächst nicht erlebbar.
**Vorschlag:** Eine ausdrücklich auswählbare fiktive Geschichte mit etwa sechs bis zehn abwechslungsreichen Momenten: ein Anfang, ein Erfolg, ein schwieriger Moment, ein Wendepunkt und ein Ausblick. Wenige passende Bilder und kurze, zusammenhängende Texte reichen. Keine große zufällige Datensammlung für die erste Vorführung.
Die leere eigene Demo bleibt erhalten. Beispielgeschichte und eigene Eingaben werden klar unterschieden; Beispiele werden nicht ungefragt in den eigenen lokalen Bestand eingefügt. Für betreute Präsentationen kann eines der bestehenden API-Testkonten einen definierten Präsentationsstand enthalten.
**Prüfkriterium:** Nach dem Öffnen versteht man anhand der Geschichte, welchen Mehrwert die Welle gegenüber einer bloßen Ereignisliste bietet.
### V03 – Leerzustand mit einer sichtbaren ersten Handlung
**Beobachtung:** Im frischen lokalen Browserzustand sind eine dunkle Fläche, Logo und Symbolschaltflächen sichtbar. Ein erklärender Text oder eine Anleitung zum ersten Moment fehlt.
**Vorschlag:** In der Mitte eine zurückhaltende Einladung: „Deine Lebenslinie beginnt mit einem Moment.“ Darunter **„Ersten Moment hinzufügen“** und optional „Beispiel ansehen“. Zoomregler können bis zum ersten Inhalt zurücktreten. Beim ersten befüllten Aufruf genügen zwei kurze Hinweise: „Seitlich bewegen“ und „Moment antippen“.
**Prüfkriterium:** Der Leerzustand wirkt beabsichtigt; niemand muss das Plus-Symbol erraten oder die Ansicht für einen Ladefehler halten. Die Einführung bleibt überspringbar und später erneut erreichbar.
### V04 – Sprache und Markenauftritt vereinheitlichen
**Beobachtung:** „That’s Me“ und „ThatsMe“ stehen nebeneinander. Texte mischen unter anderem „Login“, „Welcome back“, „Remember me“, „Key Image“, „Emotional Level“, „Speed“ und deutsche Bezeichnungen. Die Sprachauswahl ist vorhanden, viele sichtbare Texte sind weiterhin fest vorgegeben.
**Vorschlag:** Für die erste Werbedemo eine durchgehend deutsche Fassung festlegen. Beispielsweise „Titelbild“, „Wie hast du dich gefühlt?“, „Geschwindigkeit“ und „Vorlage“. Eine einheitliche Schreibweise der Marke wählen. Eine Englisch-Auswahl erst als fertige Option präsentieren, wenn der gezeigte Ablauf tatsächlich durchgehend übersetzt ist.
„Angemeldet bleiben“ nur anzeigen, wenn die Auswahl eine nachvollziehbare Wirkung hat. Für eine lokale Demo ohne Anmeldung wird sie nicht benötigt.
**Prüfkriterium:** Texte, Datumsdarstellung und Schaltflächen wirken wie ein zusammengehöriges Produkt; sichtbare Einstellungen halten ihr Versprechen.
### V05 – Menüs auf funktionierende und relevante Ziele konzentrieren
**Beobachtung:** Das Menü bietet bereits „Plan upgraden“, „Personalisierung“ und mehrere Hilfeziele. Im aktuellen Layout werden als Navigationsziele nur „Einstellungen“ und „Abmelden“ umgesetzt. In den allgemeinen Einstellungen existieren außerdem „Kommt bald“-Bereiche.
**Vorschlag:** Für die Demo ein kompaktes Menü: **„So funktioniert’s“**, **„Darstellung“**, **„Feedback“**, **„Über das Projekt“** und der passende Konto-/Demo-Abschluss. Nur tatsächlich verfügbare Ziele anzeigen. Geplante Funktionen können gebündelt in einem kurzen Ausblick stehen. „Plan upgraden“ weglassen, solange kein buchbarer Plan existiert.
**Prüfkriterium:** Jeder sichtbare Menüpunkt führt zu einem verständlichen Ergebnis. Die Demo vermittelt einen klaren Funktionsumfang.
## 4. Darstellung und Orientierung in der Timeline
### V06 – Bedienelemente vor dem Hintergrund klar erkennbar machen
**Beobachtung:** In der betrachteten hellen Oberflächeneinstellung bleibt die Hauptfläche dunkel; mehrere Symbole erscheinen darauf dunkelgrau. Große helle Ereignispunkte ziehen deutlich mehr Aufmerksamkeit auf sich als die primären Aktionen.
**Vorschlag:** Die Kontraste der Timeline-Bedienelemente auf den tatsächlichen Hintergrund abstimmen. Die zentrale Aktion „Moment hinzufügen“ darf stärker hervortreten, Gestaltungsoptionen bleiben sekundär. Transparenz und Leuchteffekte gezielt einsetzen, damit Text und Schaltflächen auch vor einem Foto ruhig lesbar bleiben.
Das Logo sollte eine erwartbare Funktion haben, etwa zum Anfang führen. Der aktuell darüber erreichbare Wechsel der Helligkeitsdarstellung gehört sichtbar in die Darstellungseinstellungen.
**Prüfkriterium:** Auf dunklem, hellem und bildbasiertem Hintergrund bleiben Aktionen und Beschriftungen klar erkennbar. Kontrast zusätzlich messen, nicht allein nach Screenshot beurteilen.
### V07 – Ereignisinhalte größer, Punkte ohne Bild aussagekräftiger
**Beobachtung:** Die Standardbeschriftung verwendet sehr kleine Titel und Datumszeilen; längere Titel werden früh gekürzt. Im Desktopbeispiel dominieren große weiße Kreise, während nur Textanfänge wie „Ein Neuanfang mi…“ lesbar bleiben.
**Vorschlag:** Als Gestaltungsstart Titel etwa im Bereich 14–16 CSS-Pixel und Datum etwa 12–14 CSS-Pixel erproben. Beim fokussierten Moment zwei Titelzeilen oder eine kleine vollständige Infokarte erlauben. Bilderlose Momente erhalten eine dezente farbige Fläche, ein Symbol oder einen Anfangsbuchstaben statt eines großen rein weißen Kreises.
Größe und Leuchten des Punkts sollten die Geschichte unterstützen. Bei enger Darstellung lieber weniger ausführlich beschriftete Momente gleichzeitig zeigen als alle Texte zu verkleinern.
**Prüfkriterium:** Reale Titel mit 30–50 Zeichen lassen sich auf Mobilgeräten erkennen; der ausgewählte Moment ist ohne Öffnen des Editors eindeutig identifizierbar. Die vorgeschlagenen Schriftgrößen sind Gestaltungswerte, keine behaupteten Normgrenzen.
### V08 – Bedeutung von Höhe, Farbe und Zeitabstand erklären
**Beobachtung:** Die Höhe eines Punkts folgt dem Gefühlswert. Farben können zusätzlich individuell überschrieben werden. Ereignisse stehen gleichmäßig auseinander, obwohl ihre Datumsabstände unterschiedlich sein können.
**Vorschlag:** Eine kurze, ausblendbare Erklärung: „Oben: positiv erlebt · Mitte: neutral · Unten: schwierig erlebt“. Die Farbe ergänzt die Information; eine Textbezeichnung sollte ebenfalls erreichbar sein. Bei freier Punktfarbe deren gestalterische Rolle klar lassen.
Den aktuellen Modus als **Ereignisfolge** erläutern: Die Abstände entsprechen nicht der verstrichenen Zeit. Eine echte Zeitskala wäre eine spätere alternative Ansicht. Für die erste Demo reicht eine verständliche Erklärung der vorhandenen Darstellung.
**Prüfkriterium:** Testpersonen verwechseln einen langen horizontalen Abstand nicht mit einer langen Lebensphase. Die Welle wird als persönliche Einordnung verstanden.
### V09 – Momente direkt wiederfinden
**Beobachtung:** Scrollen, Zoom und Jahresnavigation sind vorhanden. Bei umfangreicheren Geschichten fehlt ein direkter Einstieg zu einem gesuchten Titel oder Datum.
**Vorschlag:** Zunächst **„Zu einem Jahr springen“** und **„Zum neuesten Moment“** ergänzen. Danach eine einfache Suche nach Titel/Ort und eine kompakte chronologische Liste. Ein Treffer führt zum passenden Punkt in der Welle. Eine Gesamtübersicht mit markiertem sichtbarem Ausschnitt erst dann ergänzen, wenn lange Timelines tatsächlich häufig genutzt werden.
**Prüfkriterium:** Ein bekanntes Ereignis wird ohne langes seitliches Suchen gefunden. Eine Listenansicht ergänzt die charakteristische Welle und bietet einen zweiten verständlichen Zugang.
## 5. Erfassen, Lesen und Rückmeldungen
### V10 – Lesen und Bearbeiten als unterschiedliche Situationen gestalten
**Beobachtung:** Das Antippen eines Moments öffnet unmittelbar ein großes Bearbeitungspanel. Auf Desktopbreite erstrecken sich Eingabefelder und Gefühlsregler weit über den Bildschirm; ein Großteil der Timeline wird überdeckt.
**Vorschlag:** Für eine Beispielgeschichte zunächst eine lesbare Detailkarte mit Bild, Datum, Ort und Text zeigen; „Bearbeiten“ ist eine eigene Aktion. Für eigene neu angelegte Momente direkt im Editor bleiben. Auf großen Bildschirmen eine begrenzte Seitenansicht erproben, damit der ausgewählte Moment in der Timeline sichtbar bleibt. Mobil passt das bestehende Panel von unten gut.
Im Editor Titel und Datum früh platzieren. Das optionale Titelbild sollte den Einstieg nicht stärker dominieren als der eigentliche Moment.
**Prüfkriterium:** Besucher können eine Geschichte lesen, ohne versehentlich Inhalte zu verändern. Lange Desktopfelder werden vermieden; auf Mobilgeräten bleibt die Eingabe übersichtlich.
### V11 – Panels mit eindeutigem Abschluss und verlässlicher Tastaturführung
**Beobachtung:** Event- und Gestaltungspanel setzen stark auf Ziehen und den Hintergrund zum Schließen. Eine deutlich beschriftete Abschlussaktion fehlt dort. Im Desktopdurchlauf schloss Escape das Event-Panel nicht.
**Vorschlag:** Eine gut erkennbare Aktion **„Fertig“** bzw. **„Schließen“** im Panelkopf ergänzen. „Fertig“ schließt bei Autosave die Bearbeitung und ersetzt nicht den Speicherstatus. Escape, sinnvolle Fokusreihenfolge und Rückkehr zum zuvor geöffneten Moment vorsehen. Bei Fehlermeldungen bleibt eindeutig, ob Änderungen noch offen sind.
**Prüfkriterium:** Ein Moment lässt sich vollständig ohne Ziehgeste und mit Tastatur öffnen, bearbeiten und verlassen. Tastaturfokus wandert bei geöffnetem Dialog nicht unbemerkt hinter das Panel.
### V12 – Touchziele, Vergrößerung und mobile Bildschirmränder
**Beobachtung:** Einige Schaltflächen sind ausschließlich Symbole; Zoom ist ein sehr schmaler Regler. Die Ereignispunkte haben bereits Tastaturfunktionen, andere Aktionen bieten diese Orientierung noch nicht überall. Die aktuelle Seitendefinition schränkt Browservergrößerung ein.
**Vorschlag:** Aussagekräftige Namen für Symbolaktionen und sichtbare Fokushervorhebung. Für häufige mobile Aktionen ungefähr 44 × 44 CSS-Pixel große Trefferflächen als Komfortziel erproben; die sichtbaren Icons dürfen kleiner bleiben. Seitenvergrößerung unabhängig vom Timeline-Zoom ermöglichen. Untere Aktionen und Panelabschlüsse oberhalb von Geräte- und Tastaturrändern halten.
WCAG 2.2 nennt für das Mindestkriterium 24 × 24 CSS-Pixel bzw. definierte Ausnahmen wie ausreichenden Abstand. Das größere Komfortziel hier ist keine pauschale Behauptung dieser Mindestanforderung. [W3C: Target Size](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)
**Prüfkriterium:** Plus, Zurück, Zoom und Schließen sind einhändig gut bedienbar. Mit vergrößertem Text und geöffneter Bildschirmtastatur bleiben Eingabe und Abschluss erreichbar. Auf echten Geräten prüfen.
### V13 – Anlegen und Löschen mit klaren Erwartungen
**Beobachtung:** Schon das Öffnen über Plus erzeugt einen neuen Moment mit Standardtitel. Das Entfernen eines gesamten Events ist direkt erreichbar; für das Titelbild gibt es dagegen bereits eine ausdrückliche Bestätigung.
**Vorschlag:** Ein neuer, noch unberührter Moment wird als Entwurf verständlich behandelt. Wer sofort schließt, sollte nicht überraschend eine Sammlung leerer „Neues Event“-Einträge erzeugen. Für bestehende Momente eine knappe Löschbestätigung oder ein tatsächlich funktionierendes „Rückgängig“ anbieten. Die Bild- und Eventlöschung sprachlich konsistent gestalten.
**Prüfkriterium:** Ein versehentliches Plus ist folgenlos verlassbar. Nutzer erkennen vor dem Löschen, ob nur ein Bild oder der ganze Moment betroffen ist. Rückgängig nur versprechen, wenn es auch vollständig wiederherstellt; das kann Backend-Unterstützung erfordern.
### V14 – Vorhandenen Speicherstatus um Kontext und Handlungen ergänzen
**Beobachtung:** Die neuen Speicheranzeigen und Fehlermeldungen sind eine gute Grundlage. Ein allgemeines „Gespeichert“ erklärt noch nicht den Unterschied zwischen lokaler Demo und API-Testkonto; kurz eingeblendete Meldungen können übersehen werden.
**Vorschlag:** Im lokalen Modus **„In diesem Browser gespeichert“**, bei erfolgreicher API-Speicherung **„Im Testkonto gespeichert“**. Bei nicht bestätigtem Speichern eine bleibende Rückmeldung mit passender Handlung, etwa „Erneut versuchen“. Beim Anzeigen eines älteren lokalen Stands einen dezenten dauerhaften Hinweis statt nur einer kurzlebigen Benachrichtigung verwenden.
**Prüfkriterium:** Nutzer können jederzeit erkennen, ob die letzte Änderung bestätigt ist und wo sie liegt. Wiederholen, Offline-Verhalten und tatsächliche Persistenz müssen dazu passen; hier wird keine bereits vollständige Offline-Lösung unterstellt.
### V15 – Bilder mit Vorschau, Fortschritt und klarer Galerie ergänzen
**Beobachtung:** Titelbild, weitere Bilder und Galerie sind bereits vorhanden. Der Auswahlablauf bietet in der betrachteten UI keine durchgängige Fortschritts-/Wartedarstellung pro Bild.
**Vorschlag:** Nach der Auswahl sofort eine Vorschaukachel mit „Wird vorbereitet“ bzw. „Wird hochgeladen“ zeigen. Bei mehreren Bildern beispielsweise „2 von 5 fertig“. Fehler bleiben am betroffenen Bild erkennbar. Für das runde Timeline-Bild einen anpassbaren Bildausschnitt bzw. Fokuspunkt anbieten.
Die Galerie erhält eine klare Positionsangabe „3 von 8“, Bildtitel und bei Bedarf Bildbeschreibung. „Weitere Bilder“ ist präziser als „Weitere Medien“, solange dort nur Bilder unterstützt werden.
**Prüfkriterium:** Es ist erkennbar, ob die Auswahl angenommen wurde, ob ein Bild noch verarbeitet wird und welches fehlgeschlagen ist. Die Person oder das Motiv bleibt im runden Ausschnitt sichtbar. Fokuspunkt und zusätzliche Bildtexte können eine Erweiterung der gespeicherten Metadaten erfordern.
## 6. Gestaltung und wahrgenommene Performance
### V16 – Gute Vorlagen zuerst, Feinabstimmung danach
**Beobachtung:** Der Gestaltungsbereich enthält viele Regler, darunter Geschwindigkeit, Linienanzahl, Fächerbreite und Kurve. Eigene Vorlagen lassen sich bereits speichern, stehen aber weit unten im Ablauf.
**Vorschlag:** Oben drei bis fünf fertige, visuell unterscheidbare Vorlagen als kleine Vorschaukarten, etwa „Ruhig“, „Leuchtend“, „Minimal“ und „Fotohintergrund“. Danach wenige häufige Optionen; die übrigen Regler unter „Feinabstimmung“. Klar zwischen **Darstellung der Lebenslinie** und **allgemeinen Einstellungen** unterscheiden. FPS-Anzeige für den Testbereich aufbewahren.
**Prüfkriterium:** Ein neuer Besucher findet mit einer Auswahl eine schöne Darstellung. Eigene gespeicherte Vorlagen bleiben verfügbar; Experimentieren lässt sich einfach zurücksetzen.
### V17 – Ruhiger Modus, sparsame Darstellung und sichtbarer Ersatz
**Beobachtung:** Animation und Leuchten sind ein zentrales Gestaltungsmittel. Adaptive Qualität und Tab-Pause existieren bereits. Ein klarer Nutzerweg für reduzierte Bewegung oder eine Darstellung ohne verfügbare WebGL-Grafik ist in der betrachteten Oberfläche nicht erkennbar. „Color Lines“ beschreibt zunächst die Liniengestaltung und sollte nicht automatisch als energiesparender Modus verstanden werden.
**Vorschlag:** **„Bewegung reduzieren“** und bei Bedarf **„Sparsame Darstellung“** anbieten. Systemwünsche zu reduzierter Bewegung berücksichtigen. Während längerer Texteingaben darf der Hintergrund ruhiger werden. Bei nicht verfügbarer Grafik bleibt eine einfache Lebenslinie oder Liste mit denselben Inhalten bedienbar.
Nicht notwendige bewegte Effekte abschalten zu können, hilft Menschen mit Bewegungsempfindlichkeit. [W3C: Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html)
**Prüfkriterium:** Alle Kernaktionen funktionieren auch ohne bewegten Hintergrund. Ob ein Modus tatsächlich Energie spart, auf realen Geräten messen; ein anderes Farbschema allein ist kein Nachweis.
### V18 – Beim Laden zuerst Orientierung und Inhalte zeigen
**Vorschlag:** Logo, erklärender Text und grundlegende Bedienung früh sichtbar machen. Während Daten geladen werden, „Deine Momente werden geladen“ anzeigen; einen tatsächlich leeren Bestand davon unterscheiden. Bildflächen mit stabilen Platzhaltern reservieren, damit Beschriftungen nicht springen. Bei umfangreichen Geschichten zuerst einen benutzbaren Ausschnitt zeigen.
Ein dezenter Übergang reicht. Lange Startanimationen oder ein künstlich verzögerter Einstieg würden der schnellen Demo-Erfahrung entgegenstehen.
**Prüfkriterium:** Auch bei langsamer Verbindung bleibt erkennbar, was gerade passiert. Die erste Ansicht ist benutzbar, bevor alle Galeriebilder bereitstehen. Dies ist eine Gestaltungsempfehlung; eine aktuelle Produktionsladezeit wurde nicht gemessen.
### V19 – Performance anhand von Nutzeraktionen messen
**Vorschlag:** Für Optimierungen dieselben kurzen Abläufe vergleichen: Demo öffnen, Timeline bewegen, Moment antippen, Text eingeben, Bild auswählen und Panel schließen. Kleine Beispieldaten und eine große Testgeschichte getrennt betrachten. Ein durchschnittliches Smartphone und eine langsame Mobilverbindung sind für die Werbedemo aussagekräftiger als nur ein schneller Entwicklungsrechner.
Als technische Begleitziele bieten sich die üblichen guten Core-Web-Vitals-Bereiche an: LCP bis 2,5 Sekunden, INP bis 200 Millisekunden und CLS bis 0,1, jeweils anhand des 75. Perzentils realer Besuche beurteilt. Für neue Demos zunächst reproduzierbare Labortests nutzen und später reale Nutzung ergänzen. [Web Vitals](https://web.dev/articles/vitals)
Für die Welle zusätzlich flüssige Bewegung und eine sofortige sichtbare Reaktion prüfen. Ein Versuchsziel kann 60 Bilder/s im normalen Modus und stabile 30 Bilder/s in einem bewusst sparsamen Modus sein; das ist ein Projektziel, keine vorhandene Messung oder Zusage.
**Prüfkriterium:** Jede Performanceänderung verbessert einen dokumentierten Nutzerablauf. FPS allein reicht nicht: Text darf nicht verzögert erscheinen und Panels sollen sich unmittelbar öffnen.
## 7. Sinnvolle Erweiterungen für Werbung und Produktlernen
### V20 – Nach der Demo einen passenden nächsten Schritt anbieten
**Beobachtung:** Das angestrebte Ziel ist Interesse am Produkt. In der aktiven App ist dafür noch kein klarer Abschluss der Demo erkennbar; „Plan upgraden“ passt nicht zum aktuellen Projektstand.
**Vorschlag:** Nach dem Erkunden der Geschichte eine zurückhaltende Einladung **„Über den Start informiert werden“** oder **„Interesse am Projekt?“** anbieten. Die Demo bleibt vorher frei erlebbar. Dazu ein kurzer Projektstand: Was lässt sich bereits ausprobieren, was ist geplant?
Ein späterer Crowdfunding-Link kommt hinzu, sobald eine konkrete Kampagne existiert. Für eine Interessentenliste muss ein tatsächlicher Anmeldeweg samt Bestätigung vorhanden sein; das ist eine zusätzliche Funktion bzw. Dienstanbindung, kein reiner Textwechsel.
**Prüfkriterium:** Interessierte wissen, wie sie dranbleiben können. Der nächste Schritt verspricht nur etwas, das tatsächlich angeboten wird.
### V21 – Kurzes, konkretes Feedback ermöglichen
**Vorschlag:** Zwei Fragen reichen zunächst: **„Was würdest du hier festhalten?“** und **„Was war unklar?“** Optional eine kleine Auswahl gewünschter Funktionen. Den Einstieg im Menü und am Ende der Beispielgeschichte anbieten.
Fehlermeldung und allgemeines Produktfeedback sollten unterscheidbar sein. Einen funktionierenden Empfänger bzw. Speicherweg und eine Eingangsbestätigung vorsehen. Keine vorgetäuschte „Gesendet“-Meldung ohne tatsächliche Verarbeitung.
**Prüfkriterium:** Rückmeldungen liefern konkrete Anwendungsfälle und Bedienhürden, anstatt lediglich zu fragen, ob die Animation gefällt.
### V22 – Präsentationsmodus und gezielte Ausgabeformate
**Vorschlag für die nächste Stufe:** Ein Präsentationsmodus blendet Bearbeitungs- und Testelemente aus, zeigt vollständige Titel und erlaubt „Nächster Moment“. Damit lassen sich die vorhandenen Testgeschichten in Gesprächen und Bildschirmaufnahmen erklären. Eine automatisch ablaufende Tour wäre optional, jederzeit pausierbar und später zu prüfen.
**Spätere Ausgabeidee:** Ausgewählte Momente als gestaltete Bildkarte oder kurzer Story-Ausschnitt ausgeben, mit Vorschau in Hochformat und Querformat. Der Nutzer bestimmt, welche Titel, Bilder und Texte enthalten sind. Für die ersten Social-Media-Beiträge reichen kuratierte Bildschirmaufnahmen; ein eigener Videoexport wäre deutlich aufwendiger.
**Prüfkriterium:** Eine kurze Vorführung vermittelt eine zusammenhängende Geschichte. Exportiert oder geteilt werden nur die bewusst ausgewählten Inhalte; das Ergebnis ist auf einem Smartphone lesbar.
### V23 – Persönliche Kapitel und Kategorien zunächst als Idee testen
**Vorschlag:** Größere Geschichten könnten später eigene Kapitel erhalten, etwa „Ausbildung“, „Reisen“ oder „Neuanfang“, dazu wenige frei wählbare Kategorien. In der Welle sollen diese Orientierung geben und sich bei Bedarf filtern lassen.
Vor einer Umsetzung mit Interessenten prüfen, ob sie eher eine Lebensgeschichte, ein Reisetagebuch oder einzelne Erinnerungen festhalten möchten. Davon hängt ab, ob Kapitel, Tags oder mehrere Lebenslinien überhaupt hilfreich sind. Die Funktion würde Datenmodell und Persistenz erweitern.
**Prüfkriterium:** Die Struktur hilft beim Wiederfinden eigener Inhalte. Sie vergrößert nicht unnötig das Formular für jeden neuen Moment.
### V24 – Demo kontrolliert neu beginnen können
**Vorschlag:** Für selbstständiges Ausprobieren klar zwischen **„Eigene lokale Demo leeren“** und **„Beispielgeschichte neu starten“** unterscheiden. Vor dem Löschen eigener lokaler Eingaben eine verständliche Bestätigung zeigen. Für betreute API-Präsentationen einen getrennten, bewusst ausgelösten Reset des definierten Beispielstands vorsehen.
Der lokale Demo-Zugang bleibt leer startbar. Ein öffentlicher Reset darf nicht unbemerkt die gemeinsamen Daten anderer Tester zurücksetzen. Ein Reset der API-Testkonten ist eine zusätzliche Backend-/Betriebsfunktion und gehört nicht als beliebige globale Aktion in jede Besucheransicht.
**Prüfkriterium:** Eine Vorführung lässt sich wiederholen, ohne ungeklärte Altzustände; gleichzeitig bleiben eigene und gemeinsam verwendete Testdaten nachvollziehbar getrennt.
## 8. Empfohlene erste Umsetzungspakete
### Paket 1 – Einstieg und glaubwürdiger Funktionsumfang
V01–V05: direkter Einstieg, eine gute Beispielgeschichte, hilfreicher Leerzustand, konsistente Texte und ein auf verfügbare Funktionen begrenztes Menü. Das dürfte für neue Interessenten den größten sofortigen Unterschied machen.
### Paket 2 – Gut lesbare und sichere Kernbedienung
V06–V08 sowie V11–V13: sichtbare Aktionen, lesbare Momente, verständliche Welle, eindeutige Panelabschlüsse, gute Touchbedienung und nachvollziehbares Anlegen/Löschen. V17 parallel als ruhige Alternative berücksichtigen.
### Paket 3 – Vorführung und Interesse verbinden
V20 plus ein einfacher Präsentationsmodus aus V22; V21 für Feedback. Erst danach weitere Navigation, Gestaltungsvorlagen und Komfortfunktionen nach beobachteten Nutzerproblemen ergänzen. Eigene Videoerzeugung, umfangreiche Kategorien und vollständige Mehrsprachigkeit sind keine Voraussetzung für die erste aussagekräftige Demo.
## 9. Kleiner Nutzertest zur Auswahl der nächsten Schritte
Mit etwa fünf Personen außerhalb der Entwicklung einzeln beobachten; die geringe Zahl liefert qualitative Hinweise, keine repräsentativen Marktwerte. Zu Beginn möglichst wenig erklären.
| Aufgabe | Beobachten |
| --- | --- |
| „Was glaubst du, macht dieses Produkt?“ | Wird das Produktversprechen vor dem Login verstanden? |
| „Schau dir eine Beispielgeschichte an.“ | Wird ein sinnvoller Einstieg gefunden? |
| „Finde einen bestimmten Moment und erkläre die Welle.“ | Sind Navigation, Farbe, Höhe und Zeitabstände verständlich? |
| „Lege einen eigenen Moment an und ändere ihn.“ | Sind Titel, Datum, Gefühl und Speicherstatus klar? |
| „Verlasse die Bearbeitung und finde den Moment wieder.“ | Funktionieren Abschluss und Orientierung ohne Hilfe? |
| „Was würdest du als Nächstes tun?“ | Gibt es einen verständlichen Weg zu Feedback oder Projektupdates? |
Zusätzlich kurze Durchläufe mit Tastatur, größerem Text, reduzierter Bewegung und auf einem echten Smartphone durchführen. Bei Problemen zuerst die betroffene Hauptaktion vereinfachen, bevor weitere Funktionen hinzukommen.
## 10. Bezug zum aktuellen Frontend
Die Links dienen zur Zuordnung der Vorschläge; sie sind keine Implementierungsanleitung.
| Oberfläche | Aktuelle Quelle | Relevante Vorschläge |
| --- | --- | --- |
| Einstieg | [LoginPage.vue](../frontend/src/pages/LoginPage.vue) | V01–V04 |
| Hauptansicht und Menüs | [LifeWaveLayout.vue](../frontend/src/layouts/LifeWaveLayout.vue), [UserMenu.vue](../frontend/src/components/UserMenu.vue) | V03, V05–V06, V14, V20–V22 |
| Timeline und Punkte | [TimelineView.vue](../frontend/src/components/TimelineView.vue), [GlowDot.vue](../frontend/src/components/GlowDot.vue) | V07–V09, V12, V18–V19 |
| Ereignisse und Bilder | [EventPanel.vue](../frontend/src/components/EventPanel.vue) | V10–V15 |
| Darstellung und Einstellungen | [LifeWaveSettings.vue](../frontend/src/components/LifeWaveSettings.vue), [AppSettingsModal.vue](../frontend/src/components/AppSettingsModal.vue) | V04–V05, V16–V17 |
| Animation | [FloatingLines.vue](../frontend/src/components/FloatingLines.vue) | V17–V19 |
| Kleine Bedienelemente und Panels | [AddEventButton.vue](../frontend/src/components/AddEventButton.vue), [ZoomControl.vue](../frontend/src/components/ZoomControl.vue), [ModalCard.vue](../frontend/src/components/ModalCard.vue) | V06, V11–V12 |
| Seitendarstellung | [index.html](../frontend/index.html) | V12 |
Der [technische Audit](./AUDIT-2026-09-17.md) bleibt ein separates Dokument mit seinem jeweiligen Prüfstand. Diese Vorschlagsliste berücksichtigt sichtbare zwischenzeitliche Verbesserungen, bestätigt aber nicht pauschal die Erledigung aller dortigen Befunde.

View file

@ -0,0 +1,356 @@
## Alter 25
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 25 | Januar | Beruf | Ein Kollege fotografiert Jonas stolz vor seinen ausgebreiteten Bauplänen, kurz nachdem er sein erstes eigenständiges Projekt übertragen bekommen hat. | Fremdaufnahme | Positiv |
| 25 | Februar | Alltag | Nach einer hohen Nachzahlung kocht Jonas sonntags große Portionen zum Einfrieren; ein Selfie zeigt ihn mit Kochlöffel vor Töpfen voller Eintopf. | Selfie | Neutral |
| 25 | März | Freundschaft | Jonas und David machen nach ihrer ersten gemeinsamen Laufrunde ein verschwitztes Selfie am Rheinufer. | Selfie | Positiv |
| 25 | April | Freizeit | Mit seiner neuen Systemkamera macht Jonas im Spiegel des Fotoladens ein Selfie – sein erstes Bild mit der neuen Ausrüstung. | Selfie | Positiv |
| 25 | Mai | Familie | Bei der kleinen Feier zu Leas Ausbildungsabschluss macht ein Verwandter ein Foto von Jonas und seiner strahlenden Schwester. | Fremdaufnahme | Positiv |
| 25 | Juni | Beruf | Spätabends macht ein Kollege ein Foto vom fast leeren Büro, in dem nur noch Jonas zwischen Plänen und Pizzakartons sitzt. | Fremdaufnahme | Negativ |
| 25 | Juli | Reisen | Am Lagerfeuer in Slowenien entsteht mit Selbstauslöser ein Gruppenfoto von Jonas und seinen zwei Freunden. | Selfie | Sehr positiv |
| 25 | August | Wohnen | Jonas macht ein Selfie mit Farbe im Gesicht vor der frisch gestrichenen Wohnzimmerwand. | Selfie | Neutral |
| 25 | September | Beruf | Nach dem Jahresgespräch macht ein Kollege beim spontanen Umtrunk ein Foto der Runde. | Fremdaufnahme | Positiv |
| 25 | Oktober | Liebe | Auf der Geburtstagsfeier macht der Gastgeber ein Gruppenfoto, auf dem Jonas und Mara nebeneinanderstehen – ihr erstes gemeinsames Bild überhaupt. | Fremdaufnahme | Positiv |
| 25 | November | Liebe | Bei einem ihrer Treffen macht Mara spontan ein Selfie von beiden vor einem Kölner Café. | Selfie | Positiv |
| 25 | Dezember | Familie | Jonas' Mutter Karin fotografiert die Weihnachtsrunde, auf der Mara erstmals mitten in der Familie zu sehen ist. | Fremdaufnahme | Positiv |
## Alter 26
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 26 | Januar | Liebe | Nach dem Gespräch über die feste Beziehung macht Mara ein Selfie von beiden. | Selfie | Sehr positiv |
| 26 | Februar | Beruf | Ein Kollege fotografiert Jonas, wie er über den neuen technischen Vorschriften für das Sanierungsprojekt brütet. | Fremdaufnahme | Neutral |
| 26 | März | Familie | Bei der Beerdigung seiner Großmutter Erna macht ein Verwandter ein Foto der Trauergemeinde; Jonas steht sichtlich ergriffen neben seinem Vater. | Fremdaufnahme | Sehr negativ |
| 26 | April | Freundschaft | David macht ein Selfie mit seiner neuen Freundin nach dem Lauftreffen; die Runden mit Jonas werden seltener. | Selfie | Neutral |
| 26 | Mai | Liebe | Vor einer Amsterdamer Gracht macht ein Passant ein Foto von Jonas und Mara auf ihrer ersten gemeinsamen Wochenendreise. | Fremdaufnahme | Positiv |
| 26 | Juni | Beruf | Nach der nervenaufreibenden Präsentation macht ein Kollege ein Foto von Jonas mit erleichtertem Grinsen im Besprechungsraum. | Fremdaufnahme | Positiv |
| 26 | Juli | Familie | Sein Vater Thomas fotografiert Jonas beim Abschleifen des restaurierten Schreibtisches im Elternhaus. | Fremdaufnahme | Neutral |
| 26 | August | Freizeit | Nach dem Zieleinlauf seines ersten Halbmarathons macht ein Streckenfotograf ein Bild von Jonas mit erschöpftem, glücklichem Gesicht; Mara und David jubeln im Hintergrund. | Fremdaufnahme | Sehr positiv |
| 26 | September | Liebe | Ein Selfie vom gemeinsamen Abendessen wirkt harmonisch, entstand aber am Abend ihres ersten größeren Streits über Jonas' Überstunden. | Selfie | Negativ |
| 26 | Oktober | Liebe | Nach der Versöhnung machen Jonas und Mara ein Selfie bei ihrem neu eingeführten festen Dienstagsabend. | Selfie | Positiv |
| 26 | November | Beruf | Ein Foto vom Baustellen-Koordinationstreffen zeigt Jonas sichtlich in seinem Element zwischen mehreren Fachplanern. | Fremdaufnahme | Positiv |
| 26 | Dezember | Familie | Karin macht ein Foto von Mara, die zum ersten Mal mit an der Weihnachtstafel der Familie sitzt. | Fremdaufnahme | Positiv |
## Alter 27
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 27 | Januar | Beruf | Ein Foto vom Koordinationsmeeting zeigt Jonas, wie er sichtbar aufblüht, wenn er mehrere Gewerke zusammenbringt statt allein an Plänen zu sitzen. | Fremdaufnahme | Positiv |
| 27 | Februar | Persönliche Entwicklung | Nachdem seine Investition in die Geschäftsidee eines Bekannten gescheitert ist, hilft Jonas beim Ausräumen von dessen halbfertigem Ladenlokal; ein Foto zeigt ihn beim Tragen leerer Regale. | Fremdaufnahme | Negativ |
| 27 | März | Liebe | Mara macht ein Selfie mit dem neuen Wohnungsschlüssel in der Hand vor Jonas' Wohnungstür. | Selfie | Positiv |
| 27 | April | Weiterbildung | Der Kursleiter fotografiert die neue Projektmanagement-Gruppe am ersten Kursabend, Jonas in der zweiten Reihe. | Fremdaufnahme | Positiv |
| 27 | Mai | Freundschaft | David macht ein Selfie mit Jonas und dem neuen Bekannten Cem nach ihrem ersten gemeinsamen Treffen. | Selfie | Positiv |
| 27 | Juni | Familie | Am Krankenbett macht Karin trotz Gipsarm ein Selfie mit Jonas, der die Nacht bei ihr verbracht hat. | Selfie | Negativ |
| 27 | Juli | Reisen | Statt der geplanten Fernreise entsteht nur ein Selfie vom gemeinsamen Grillabend auf dem Balkon. | Selfie | Neutral |
| 27 | August | Beruf | Ein Kollege fotografiert Jonas am improvisierten Vertretungs-Schreibtisch seines erkrankten Projektleiters. | Fremdaufnahme | Positiv |
| 27 | September | Wohnen | Jonas und Mara machen ein Selfie vor einem Wohnungsanzeigen-Brett, nachdem sie beschlossen haben, zusammenzuziehen. | Selfie | Positiv |
| 27 | Oktober | Weiterbildung | Nach der bestandenen Prüfung macht ein Kommilitone ein Foto von Jonas mit dem Prüfungsbogen in der Hand. | Fremdaufnahme | Positiv |
| 27 | November | Freundschaft | Ein Handyfoto, das David wütend vom leeren Beifahrersitz macht, nachdem Jonas den gemeinsamen Trip absagt, markiert den Beginn ihres Streits. | Fremdaufnahme | Negativ |
| 27 | Dezember | Freundschaft | Nach der Versöhnung machen David und Jonas ein Selfie beim gemeinsamen Bier. | Selfie | Positiv |
## Alter 28
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 28 | Januar | Wohnen | Ein Makler fotografiert Jonas und Mara vor einer Wohnung, die sie gerade besichtigt haben. | Fremdaufnahme | Positiv |
| 28 | Februar | Beruf | Kollegen fotografieren Jonas, als er offiziell zum Teilprojektleiter ernannt wird und dem Chef die Hand schüttelt. | Fremdaufnahme | Sehr positiv |
| 28 | März | Wohnen | Mara macht ein Selfie mit dem Zusage-Schreiben für die Drei-Zimmer-Wohnung in der Hand. | Selfie | Sehr positiv |
| 28 | April | Alltag | Um Geld zu sparen, kaufen Jonas und Mara gebrauchte Möbel auf dem Flohmarkt; ein Verkäufer fotografiert die beiden beim Verhandeln über einen alten Schrank. | Fremdaufnahme | Negativ |
| 28 | Mai | Wohnen | Ein Selfie vom ersten gemeinsamen Kartonauspacken zeigt Jonas und Mara umgeben von halb ausgepackten Kisten. | Selfie | Positiv |
| 28 | Juni | Liebe | Ein Selfie vom gemeinsamen Abendessen wirkt entspannt, entstand aber mitten in einer Phase kleinerer Streitigkeiten über Ordnung und Freizeit. | Selfie | Negativ |
| 28 | Juli | Reisen | An der portugiesischen Küste macht ein Strandverkäufer ein Foto von Jonas und Mara, wieder ausgelassen lachend. | Fremdaufnahme | Sehr positiv |
| 28 | August | Weiterbildung | Beim Abschluss seiner Weiterbildung macht der Kursleiter ein Gruppenfoto mit Zertifikaten in der Hand. | Fremdaufnahme | Sehr positiv |
| 28 | September | Beruf | Sein Team macht ein Foto mit Jonas als neuem Teamleiter vor dem Baustellencontainer. | Fremdaufnahme | Positiv |
| 28 | Oktober | Familie | Lea macht ein Selfie mit Jonas und ihrem Partner, als sie ihm stolz von ihrem geplanten Einzug erzählt. | Selfie | Positiv |
| 28 | November | Persönliche Entwicklung | Jonas macht ein Selfie mit der letzten Kreditrückzahlungsbestätigung in der Hand, breit grinsend. | Selfie | Sehr positiv |
| 28 | Dezember | Liebe | Ein Foto von der ersten gemeinsamen Weihnachtstafel in der neuen Wohnung, aufgenommen von Jonas' Vater, zeigt beide Familien um den Tisch versammelt. | Fremdaufnahme | Positiv |
## Alter 29
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 29 | Januar | Persönliche Entwicklung | Als sichtbares Zeichen für sein neues Sparziel macht Jonas ein Selfie mit einem alten Marmeladenglas, das er als "Wohnungskauf-Spardose" beschriftet hat. | Selfie | Neutral |
| 29 | Februar | Persönliche Entwicklung | Ein Ersthelfer-Kollege fotografiert die Szene an der Unfallstelle, im Hintergrund ist Jonas zu erkennen, der bis zum Rettungswagen wartet. | Fremdaufnahme | Positiv |
| 29 | März | Freundschaft | Cem fotografiert Jonas beim Fotografieren auf einer ihrer gemeinsamen Fototouren durch Köln. | Fremdaufnahme | Positiv |
| 29 | April | Familie | Ein Nachbar macht ein Foto von Jonas und seinen Eltern vor dem "Zu verkaufen"-Schild am Elternhaus. | Fremdaufnahme | Neutral |
| 29 | Mai | Liebe | Mara macht ein Selfie mit Jonas vor einem Schaufenster mit Babykleidung, in dem sie beide kurz stehen bleiben. | Selfie | Neutral |
| 29 | Juni | Liebe | Ein Selfie von einem langen Spaziergang am Rhein hält den Abend fest, an dem Jonas offen über seine Unsicherheit beim Thema Elternschaft spricht. | Selfie | Neutral |
| 29 | Juli | Beruf | Kollegen fotografieren Jonas mit einem Umschlag in der Hand, nachdem er für den erfolgreichen Bauabschnitt einen Bonus erhalten hat. | Fremdaufnahme | Positiv |
| 29 | August | Freizeit | Beim mehrtägigen Wanderevent macht ein anderer Teilnehmer ein Foto von Jonas und David, erschöpft, aber lachend am Gipfelkreuz. | Fremdaufnahme | Positiv |
| 29 | September | Familie | Lea macht ein Selfie mit ihrem neuen Ring und schickt es Jonas, der es sofort ausdruckt. | Selfie | Positiv |
| 29 | Oktober | Liebe | Ein Selfie von Jonas und Mara beim Betrachten von Babyfotos in Leas Fotoalbum zeigt, wie sich Jonas erstmals ein eigenes Familienleben vorstellen kann. | Selfie | Positiv |
| 29 | November | Wohnen | Ein Foto vom gemütlichen Wohnzimmerabend, aufgenommen von Mara, zeigt beide bewusst entspannt – Symbol ihrer Entscheidung, weiter zur Miete zu wohnen. | Selfie | Neutral |
| 29 | Dezember | Partnerschaft | Statt eines Rings macht Mara ein Selfie mit den Reiseunterlagen, die Jonas ihr geschenkt hat. | Selfie | Positiv |
## Alter 30
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 30 | Januar | Persönliche Entwicklung | Ein Selfie zu seinem 30. Geburtstag zeigt Jonas nachdenklicher als auf früheren Geburtstagsfotos. | Selfie | Neutral |
| 30 | Februar | Beruf | Ein Kollege fotografiert Jonas beim Zusammenstellen seiner Bewerbungsunterlagen für die intern ausgeschriebene Führungsposition. | Fremdaufnahme | Neutral |
| 30 | März | Beruf | Ein Kollege macht ein Foto vom Team-Meeting, auf dem Jonas sichtlich angespannt neben dem neu ernannten Vorgesetzten sitzt. | Fremdaufnahme | Negativ |
| 30 | April | Liebe | Ein Selfie von einem ruhigen Abend zeigt Jonas und Mara eng nebeneinander, kurz nach seiner beruflichen Enttäuschung. | Selfie | Positiv |
| 30 | Mai | Familie | Der Hochzeitsfotograf macht ein Foto von Jonas, wie er bei Leas Hochzeit seine Rede hält, Tränen in den Augen. | Fremdaufnahme | Sehr positiv |
| 30 | Juni | Reisen | Vor den Niagarafällen macht ein Tourist ein Foto von Jonas und Mara auf ihrer lange geplanten Kanada-Reise. | Fremdaufnahme | Sehr positiv |
| 30 | Juli | Beruf | Ein Foto vom Kick-off-Meeting zeigt Jonas als neuen Leiter eines anspruchsvollen Infrastrukturprojekts. | Fremdaufnahme | Positiv |
| 30 | August | Freizeit | Ein Selfie von einer seltenen Laufrunde zeigt Jonas erschöpfter als früher – seit Monaten läuft er kaum noch. | Selfie | Neutral |
| 30 | September | Liebe | Ein Selfie von Jonas vor einem Schaufenster mit Verlobungsringen, das er heimlich David schickt. | Selfie | Positiv |
| 30 | Oktober | Familie | Sein Vater Thomas fotografiert Jonas am fertig restaurierten Schreibtisch, den beide gemeinsam aufgearbeitet haben. | Fremdaufnahme | Positiv |
| 30 | November | Liebe | David macht ein Selfie mit Jonas, der ihm heimlich den gerade gekauften Verlobungsring zeigt. | Selfie | Positiv |
| 30 | Dezember | Liebe | Ein Wanderer fotografiert Jonas und Mara auf dem Bergaussichtspunkt, kurz nachdem er ihr – sichtlich nervös – einen Antrag gemacht hat. | Fremdaufnahme | Sehr positiv |
## Alter 31
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 31 | Januar | Hochzeit | Ein Selfie von Jonas und Mara vor einem Stapel Hochzeitsmagazine zeigt den Beginn ihrer Planung. | Selfie | Positiv |
| 31 | Februar | Alltag | Ein Selfie vor dem Laptop mit mehreren geöffneten Angebots-Tabs zeigt Jonas' ernüchterten Gesichtsausdruck über die hohen Kosten für Location und Feier. | Selfie | Negativ |
| 31 | März | Hochzeit | Ein Selfie von Jonas und Mara mit der reduzierten Gästeliste in der Hand, erleichtert lachend. | Selfie | Neutral |
| 31 | April | Beruf | Ein Kollege fotografiert Jonas nach dem Gespräch über seine Gehaltserhöhung mit einem breiten Grinsen. | Fremdaufnahme | Positiv |
| 31 | Mai | Familie | Ein Familienfoto beim Treffen wirkt auf den ersten Blick harmonisch, entstand aber unmittelbar nach Spannungen über die Gästeliste. | Fremdaufnahme | Negativ |
| 31 | Juni | Hochzeit | Der Hochzeitsfotograf hält den Moment fest, in dem Jonas und Mara im kleinen Kreis standesamtlich heiraten. | Fremdaufnahme | Sehr positiv |
| 31 | Juli | Reisen | Ein Highlander-Wirt fotografiert Jonas und Mara vor einem schottischen Loch auf ihrer Hochzeitsreise. | Fremdaufnahme | Sehr positiv |
| 31 | August | Alltag | Ein Selfie vom ersten "normalen" Sonntagsfrühstück nach der Hochzeitsreise zeigt beide entspannt in Jogginghosen. | Selfie | Neutral |
| 31 | September | Familie | Ein Selfie von Jonas und Mara nach dem Gespräch über ihren Kinderwunsch, beide sichtlich aufgeregt. | Selfie | Positiv |
| 31 | Oktober | Beruf | Ein Kollege fotografiert Jonas an einem übervollen Schreibtisch, nachdem ein erfahrener Kollege gekündigt hat. | Fremdaufnahme | Negativ |
| 31 | November | Alltag | Ein Selfie von Jonas und Mara am neu eröffneten gemeinsamen Sparkonto-Ordner auf dem Küchentisch. | Selfie | Neutral |
| 31 | Dezember | Familie | Mara macht ein Selfie mit dem Schwangerschaftstest in der Hand, Jonas daneben mit offenem Mund. | Selfie | Sehr positiv |
## Alter 32
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 32 | Januar | Familie | Ein Selfie aus dem Wartezimmer der Frauenärztin zeigt Jonas und Mara vor dem ersten Ultraschall. | Selfie | Sehr positiv |
| 32 | Februar | Wohnen | Ein Selfie vom halb umgeräumten Arbeitszimmer zeigt die ersten Möbelstücke für das künftige Kinderzimmer. | Selfie | Positiv |
| 32 | März | Beruf | Ein Kollege fotografiert Jonas beim Gespräch mit seinem Vorgesetzten über die geplante Elternzeit. | Fremdaufnahme | Neutral |
| 32 | April | Alltag | Ein Selfie von Jonas und Mara am Küchentisch, umgeben von Babyausstattungs-Prospekten und einem handgeschriebenen Budgetzettel. | Selfie | Neutral |
| 32 | Mai | Familie | Ein Kursleiter fotografiert die Gruppe beim Geburtsvorbereitungskurs, Jonas mit sichtlich unsicherem Blick neben Mara. | Fremdaufnahme | Neutral |
| 32 | Juni | Freundschaft | David macht ein Selfie mit seinem neugeborenen Sohn und schickt es Jonas mit den Worten "bald bist du dran". | Selfie | Positiv |
| 32 | Juli | Familie | Ein Selfie von Jonas beim Staubsaugen mit Schürze zeigt, wie er wegen Maras Beschwerden mehr Haushalt übernimmt. | Selfie | Neutral |
| 32 | August | Familie | Das Krankenhauspersonal macht ein Foto von Jonas, der seinen neugeborenen Sohn Emil zum ersten Mal im Arm hält, Tränen auf den Wangen. | Fremdaufnahme | Sehr positiv |
| 32 | September | Familie | Ein müdes Selfie um drei Uhr nachts zeigt Jonas mit dem schreienden Emil auf dem Arm. | Selfie | Negativ |
| 32 | Oktober | Familie | Ein Selfie zeigt Jonas entspannter beim Wickeln, sichtlich routinierter als noch Wochen zuvor. | Selfie | Positiv |
| 32 | November | Familie | Ein Selfie am ersten Tag der Elternzeit zeigt Jonas mit Emil im Tragetuch vor der Bürotür, die er gerade hinter sich lässt. | Selfie | Positiv |
| 32 | Dezember | Familie | Karin fotografiert das erste Weihnachten mit Emil, alle drei Generationen um den Baum versammelt. | Fremdaufnahme | Sehr positiv |
## Alter 33
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 33 | Januar | Familie | Ein Selfie vom Spielplatz zeigt Jonas und Emil an einem gewöhnlichen Vormittag während der Elternzeit. | Selfie | Positiv |
| 33 | Februar | Beruf | Ein Kollege fotografiert Jonas vor einem Whiteboard voller Notizen, während er sich nach seiner Rückkehr wieder einarbeitet. | Fremdaufnahme | Negativ |
| 33 | März | Partnerschaft | Ein Selfie vom gemeinsamen Abendessen zeigt Jonas und Mara, beide mit dem Handy neben dem Teller – Sinnbild für Gespräche, die nur noch um Organisation kreisen. | Selfie | Negativ |
| 33 | April | Partnerschaft | Ein Kellner fotografiert Jonas und Mara beim ersten Abendessen zu zweit seit Emils Geburt. | Fremdaufnahme | Positiv |
| 33 | Mai | Familie | Die Erzieherin macht am ersten Tag ein Foto von Emil in der Kindertagespflege, Jonas im Hintergrund beim Abschied. | Fremdaufnahme | Neutral |
| 33 | Juni | Beruf | Kollegen fotografieren Jonas bei seiner Beförderungsfeier zum Projektleiter mit Sektglas in der Hand. | Fremdaufnahme | Sehr positiv |
| 33 | Juli | Alltag | Ein Selfie vom Kühlschrank mit der neuen, engeren Wochenplan-Tafel zeigt, wie stark die Betreuungskosten den Alltag strukturieren. | Selfie | Neutral |
| 33 | August | Freizeit | Ein Selfie im Morgengrauen am Rhein zeigt Jonas beim ersten Lauf seit Monaten, außer Atem, aber lächelnd. | Selfie | Positiv |
| 33 | September | Familie | Ein Selfie von Jonas und seinem frisch pensionierten Vater beim gemeinsamen Kaffee zeigt Thomas ungewöhnlich still. | Selfie | Neutral |
| 33 | Oktober | Beruf | Ein Kollege fotografiert das Team-Meeting, in dem Jonas die Nachricht von der Kündigung eines Mitarbeiters verkündet. | Fremdaufnahme | Negativ |
| 33 | November | Freundschaft | Ein Selfie von einem Männerwochenende zeigt Jonas und David beim Angeln, beide sichtlich entspannt. | Selfie | Positiv |
| 33 | Dezember | Familie | Ein Familienfoto vom zweiten Weihnachten mit Emil wirkt deutlich entspannter als das Foto vom Vorjahr. | Fremdaufnahme | Positiv |
## Alter 34
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 34 | Januar | Wohnen | Ein Selfie von Jonas und Mara vor einem Immobilienportal auf dem Laptop-Bildschirm zeigt den Beginn ihrer erneuten Wohnungssuche. | Selfie | Neutral |
| 34 | Februar | Alltag | Ein Berater fotografiert Jonas und Mara nach dem ernüchternden Finanzierungsgespräch für einen Immobilienkauf. | Fremdaufnahme | Negativ |
| 34 | März | Wohnen | Ein Selfie vom ersten Besichtigungstermin für eine größere Mietwohnung zeigt beide vor einem leeren Zimmer. | Selfie | Neutral |
| 34 | April | Beruf | Ein Foto vom Baustellenbesuch zeigt Jonas, wie er einem jungen Nachwuchsingenieur etwas erklärt. | Fremdaufnahme | Positiv |
| 34 | Mai | Familie | Ein Selfie-Video-Standbild von Emil, der stolz seinen ersten langen Satz sagt, während Jonas danebensteht. | Selfie | Sehr positiv |
| 34 | Juni | Wohnen | Ein Selfie vor der Tür der neuen Vier-Zimmer-Wohnung mit Schlüssel in der Hand zeigt die ganze Familie. | Selfie | Positiv |
| 34 | Juli | Wohnen | Ein Foto vom chaotischen Umzugstag, aufgenommen von einem Umzugshelfer, zeigt Jonas erschöpft zwischen Kartonstapeln. | Fremdaufnahme | Negativ |
| 34 | August | Freizeit | Ein Selfie von der neu eingerichteten Fotoecke im neuen Zuhause zeigt Jonas mit Kamera in der Hand. | Selfie | Positiv |
| 34 | September | Partnerschaft | Ein Selfie von einem ruhigen Abendspaziergang zeigt Jonas und Mara beim Gespräch über ein mögliches zweites Kind. | Selfie | Neutral |
| 34 | Oktober | Familie | Ein Selfie von Karin mit Emil auf dem Arm zeigt den Beginn ihrer wöchentlichen Nachmittage zusammen. | Selfie | Positiv |
| 34 | November | Beruf | Kollegen fotografieren Jonas bei der internen Lobrede für sein Projekt, das das Budget unterschritten hat. | Fremdaufnahme | Sehr positiv |
| 34 | Dezember | Partnerschaft | Ein Selfie von Jonas und Mara nach einem langen Gespräch zeigt beide ruhig und einig, kein zweites Kind zu planen. | Selfie | Neutral |
## Alter 35
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 35 | Januar | Beruf | Ein Kollege fotografiert Jonas beim Gespräch über das Angebot, stellvertretender Bereichsleiter zu werden. | Fremdaufnahme | Positiv |
| 35 | Februar | Beruf | Kollegen fotografieren Jonas bei seiner ersten Personalleiter-Besprechung in neuer Rolle. | Fremdaufnahme | Positiv |
| 35 | März | Familie | Die Erzieherin macht ein Foto vom ersten Kindergartentag, Emil klammert sich an Jonas' Bein. | Fremdaufnahme | Neutral |
| 35 | April | Beruf | Ein Foto vom Konfliktgespräch zeigt Jonas zwischen zwei angespannten Mitarbeitern vermittelnd. | Fremdaufnahme | Negativ |
| 35 | Mai | Freizeit | Ein Selfie am Nordseestrand zeigt die ganze Familie beim verlängerten Wochenende. | Selfie | Positiv |
| 35 | Juni | Freundschaft | Ein letztes gemeinsames Foto von Cem und Jonas vor dessen Umzugswagen nach Hamburg. | Selfie | Negativ |
| 35 | Juli | Persönliche Entwicklung | Ein Selfie zeigt Jonas' verstaubte Kamera auf dem Schrank – seit Monaten unbenutzt. | Selfie | Neutral |
| 35 | August | Freizeit | Ein Selfie von Jonas mit Kamera im Kölner Viertel markiert den Start seines neuen Fotoprojekts. | Selfie | Positiv |
| 35 | September | Familie | Lea macht ein Selfie mit ihrem sichtbar gewölbten Bauch, Jonas hält bereits eine kleine Mütze in der Hand. | Selfie | Sehr positiv |
| 35 | Oktober | Beruf | Ein Kollege fotografiert die neue Geschäftsführung bei einer Betriebsversammlung, Jonas mit verschränkten Armen im Publikum. | Fremdaufnahme | Negativ |
| 35 | November | Partnerschaft | Ein Selfie von Jonas mit Emil beim Hausaufgaben-Nachmittag, während Mara auf ihrer Fortbildung ist. | Selfie | Neutral |
| 35 | Dezember | Familie | Ein Familienfoto zeigt Jonas mit seinem vierjährigen Sohn vor dem Weihnachtsbaum, bewusst als eigene Tradition inszeniert. | Fremdaufnahme | Positiv |
## Alter 36
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 36 | Januar | Familie | Ein Krankenhausfoto zeigt Jonas als frischgebackenen Onkel mit seiner Nichte Mia im Arm. | Fremdaufnahme | Sehr positiv |
| 36 | Februar | Beruf | Ein Selfie vor einem Stapel neuer Berichtsformulare zeigt Jonas' genervten Blick. | Selfie | Negativ |
| 36 | März | Partnerschaft | Ein Foto vom Abschlussfest der Fortbildung zeigt Mara mit Zertifikat, Jonas applaudierend daneben. | Fremdaufnahme | Positiv |
| 36 | April | Familie | Ein Selfie von Jonas und Emil beim Nachmittagsritual im Park. | Selfie | Neutral |
| 36 | Mai | Alltag | Ein Selfie vor dem neuen Gebrauchtwagen zeigt die ganze Familie beim ersten Ausflug damit. | Selfie | Neutral |
| 36 | Juni | Freizeit | Ein Besucher fotografiert Jonas vor seinen eigenen Fotografien bei seiner ersten kleinen Ausstellung. | Fremdaufnahme | Sehr positiv |
| 36 | Juli | Freundschaft | Ein Selfie beim letzten gemeinsamen Wochenende wirkt entspannt, entstand aber unmittelbar vor dem heftigen Streit, der die Freundschaft mit David monatelang auf Eis legt. | Selfie | Sehr negativ |
| 36 | August | Reisen | Ein Campingplatz-Nachbar fotografiert Jonas, Mara und Emil vor ihrem Zelt in Dänemark. | Fremdaufnahme | Positiv |
| 36 | September | Beruf | Ein Foto von der Krisensitzung zeigt Jonas mit Baukosten-Tabellen an der Wand. | Fremdaufnahme | Negativ |
| 36 | Oktober | Beruf | Ein Kollege fotografiert Jonas bei der unangenehmen Ansage der Sparmaßnahmen vor seinem Team. | Fremdaufnahme | Negativ |
| 36 | November | Familie | Ein Selfie von Jonas und seinem Vater vor dem in die Jahre gekommenen Elternhaus. | Selfie | Neutral |
| 36 | Dezember | Familie | Ein Selfie von Jonas mit seinen Eltern vor einer Wohnungsanzeige für kleinere Wohnungen. | Selfie | Positiv |
## Alter 37
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 37 | Januar | Beruf | Ein Foto von der Betriebsversammlung zeigt die angespannten Gesichter der Belegschaft bei der Ankündigung der Umstrukturierung. | Fremdaufnahme | Negativ |
| 37 | Februar | Beruf | Ein Kollege fotografiert die Abteilungsrunde, in der die geplante Verkleinerung bekannt wird, Jonas mit verschränkten Armen. | Fremdaufnahme | Negativ |
| 37 | März | Familie | Ein Selfie von Jonas und seinen Eltern bei einer weiteren Wohnungsbesichtigung. | Selfie | Neutral |
| 37 | April | Beruf | Ein Kollege macht am letzten Arbeitstag ein Erinnerungsfoto von Jonas vor seinem leergeräumten Schreibtisch. | Fremdaufnahme | Sehr negativ |
| 37 | Mai | Persönliche Entwicklung | Ein Selfie zeigt Jonas unrasiert auf dem Sofa mit dem Laptop – bis David unangekündigt vor der Tür steht und ein Foto von ihrem ersten Treffen seit dem Streit macht. | Fremdaufnahme | Neutral |
| 37 | Juni | Partnerschaft | Ein Selfie von Mara, die ihm tröstend die Hand auf die Schulter legt, während er am Küchentisch Bewerbungen schreibt. | Selfie | Positiv |
| 37 | Juli | Beruf | Ein Selfie zeigt Jonas mit einem Stapel Absagebriefe in der Hand, resigniert lächelnd. | Selfie | Negativ |
| 37 | August | Familie | Ein Selfie vor dem leeren Elternhaus zeigt Jonas mit einer letzten Kiste in der Hand. | Selfie | Neutral |
| 37 | September | Beruf | Ein Selfie vom ersten Treffen mit seinem ehemaligen Auftraggeber in einem Café zeigt Jonas mit Laptop und Kaffeetasse. | Selfie | Positiv |
| 37 | Oktober | Selbstständigkeit | Ein Selfie mit dem Gewerbeanmeldungs-Formular in der Hand markiert den Start seiner Selbstständigkeit. | Selfie | Positiv |
| 37 | November | Beruf | Ein Selfie mit drei Kaffeetassen auf dem Küchentisch symbolisiert die drei neuen Kundenanfragen an einem Tag. | Selfie | Sehr positiv |
| 37 | Dezember | Persönliche Entscheidung | Ein Selfie mit Mara vor einem selbstgemalten "Neustart"-Schild in der Küche. | Selfie | Sehr positiv |
## Alter 38
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 38 | Januar | Selbstständigkeit | Ein Selfie vor seinem neuen Schreibtisch im Gemeinschaftsbüro zeigt Jonas beim Einrichten. | Selfie | Positiv |
| 38 | Februar | Persönliche Entwicklung | Ein Selfie vom leeren Kontoauszug auf dem Bildschirm zeigt Jonas' angespannten Blick in einem schwachen Monat. | Selfie | Negativ |
| 38 | März | Selbstständigkeit | Ein Selfie mit dem unterschriebenen Sechsmonatsvertrag in der Hand. | Selfie | Sehr positiv |
| 38 | April | Familie | Ein Foto vor der Schule, aufgenommen von Mara, zeigt Jonas stolz neben Emil an dessen erstem Schultag mit Schultüte. | Fremdaufnahme | Sehr positiv |
| 38 | Mai | Partnerschaft | Ein Selfie von einem späten Abend am Laptop zeigt Jonas allein am Küchentisch, während Mara im Hintergrund unzufrieden die Arme verschränkt. | Selfie | Negativ |
| 38 | Juni | Partnerschaft | Ein Selfie, das Mara ihrer Schwester schickt, zeigt sie mit gepacktem Koffer vor deren Wohnungstür. | Selfie | Sehr negativ |
| 38 | Juli | Persönliche Entscheidung | Ein Selfie mit einem handgeschriebenen "Feierabend ab 18 Uhr"-Zettel an seinem Bildschirm. | Selfie | Positiv |
| 38 | August | Reisen | Ein Kellner fotografiert die Familie beim entspannten Abendessen in einem österreichischen Gasthof. | Fremdaufnahme | Positiv |
| 38 | September | Partnerschaft | Ein Selfie von Jonas und Mara beim ersten organisierten Paarabend seit Monaten. | Selfie | Positiv |
| 38 | Oktober | Selbstständigkeit | Ein Selfie mit einer Umsatzübersicht auf dem Bildschirm zeigt Jonas zufrieden nach mehreren stabilen Monaten. | Selfie | Positiv |
| 38 | November | Freundschaft | Ein Selfie von David, der Jonas nach dem Geständnis über seine Eheprobleme fest umarmt. | Selfie | Positiv |
| 38 | Dezember | Familie | Ein Familienfoto vor dem Weihnachtsbaum wirkt nach dem schwierigen Frühjahr wieder unbeschwert. | Fremdaufnahme | Positiv |
## Alter 39
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 39 | Januar | Selbstständigkeit | Ein Selfie mit dem unterschriebenen Rahmenvertrag in der Hand. | Selfie | Sehr positiv |
| 39 | Februar | Persönliche Entwicklung | Ein Selfie vom neu eingerichteten Rücklagen-Ordner auf dem Schreibtisch. | Selfie | Neutral |
| 39 | März | Familie | Ein Selfie aus dem Krankenhausflur zeigt Jonas nervös wartend während der Operation seiner Mutter. | Selfie | Negativ |
| 39 | April | Familie | Ein Selfie von Karin, die wieder selbstständig durch ihre Küche geht, mit Jonas im Hintergrund erleichtert lächelnd. | Selfie | Positiv |
| 39 | Mai | Freizeit | Ein Streckenfotograf hält Jonas beim entspannten, zielfreien Halbmarathon-Lauf fest. | Fremdaufnahme | Positiv |
| 39 | Juni | Partnerschaft | Ein Kellner fotografiert Jonas und Mara beim Anstoßen zu ihrem achten Hochzeitstag in Belgien. | Fremdaufnahme | Positiv |
| 39 | Juli | Beruf | Ein Selfie mit seiner neuen freien Mitarbeiterin vor dem gemeinsamen Arbeitsplatz. | Selfie | Positiv |
| 39 | August | Familie | Ein Selfie von Jonas und Emil über aufgeschlagenen Matheheften, beide mit angestrengtem Blick. | Selfie | Neutral |
| 39 | September | Freundschaft | Ein Selfie von Jonas und Cem bei ihrer ersten gemeinsamen Fototour seit Jahren. | Selfie | Positiv |
| 39 | Oktober | Selbstständigkeit | Ein Selfie mit einer überfälligen Rechnung auf dem Bildschirm zeigt Jonas' angespannten Blick. | Selfie | Negativ |
| 39 | November | Persönliche Entwicklung | Ein Selfie an seinem Arbeitsplatz zeigt Jonas zufrieden, mit einem kleinen Firmenschild im Hintergrund. | Selfie | Positiv |
| 39 | Dezember | Familie | Ein Selfie mit seinen Eltern zu Besuch zeigt Karin und Thomas sichtlich älter als auf Fotos von vor ein paar Jahren. | Selfie | Neutral |
## Alter 40
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 40 | Januar | Persönliche Entwicklung | Ein Selfie zu seinem bewusst klein gefeierten 40. Geburtstag zeigt Jonas entspannt mit Mara und Emil. | Selfie | Positiv |
| 40 | Februar | Familie | Ein Selfie von einem Besuch zeigt seinen Vater Thomas ungewöhnlich blass und erschöpft im Sessel. | Selfie | Negativ |
| 40 | März | Familie | Ein Selfie von Jonas, der seinem Vater beim Einkaufen hilft, zeigt beide beim Tütentragen. | Selfie | Neutral |
| 40 | April | Beruf | Ein Selfie mit seiner zweiten freien Fachkraft vor dem gemeinsamen Auftrag. | Selfie | Positiv |
| 40 | Mai | Familie | Ein letztes Handyfoto von Thomas im Krankenbett, aufgenommen von Karin Stunden vor seinem Tod. | Fremdaufnahme | Sehr negativ |
| 40 | Juni | Familie | Ein Foto von der Beerdigung, aufgenommen von einem Verwandten, zeigt Jonas und Lea Arm in Arm hinter dem Sarg. | Fremdaufnahme | Sehr negativ |
| 40 | Juli | Persönliche Entwicklung | Ein Selfie an seinem Schreibtisch zeigt Jonas mit leerem, abwesendem Blick mitten in der Arbeit. | Selfie | Negativ |
| 40 | August | Familie | Ein Selfie vom ruhigen Urlaub mit Mara und Emil zeigt die ersten echten Lächeln seit dem Todesfall. | Selfie | Positiv |
| 40 | September | Freizeit | Ein Selfie zeigt Jonas beim Digitalisieren alter Dias seines Vaters am Leuchttisch. | Selfie | Positiv |
| 40 | Oktober | Familie | Ein Selfie von Jonas und Lea, umgeben von Kisten mit den letzten Sachen ihres Vaters. | Selfie | Negativ |
| 40 | November | Beruf | Ein Selfie mit einem vollen Terminkalender zeigt Jonas' Rückkehr in eine normalere Arbeitsroutine. | Selfie | Neutral |
| 40 | Dezember | Familie | Ein Familienfoto vom ersten Weihnachten ohne Thomas zeigt einen sichtbar leeren Platz am Tisch. | Fremdaufnahme | Sehr negativ |
## Alter 41
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 41 | Januar | Familie | Ein Selfie von Jonas und Karin bei ihrem ersten festen Monatsbesuch. | Selfie | Positiv |
| 41 | Februar | Selbstständigkeit | Ein Selfie vor seinem neuen Firmenschild mit Logo an der Bürotür. | Selfie | Positiv |
| 41 | März | Beruf | Ein Selfie mit seinem ersten Kunden ohne Vorgeschichte im gemeinsamen Netzwerk, Handschlag vor der Kamera. | Selfie | Sehr positiv |
| 41 | April | Familie | Ein Selfie von Emil vor seiner neuen weiterführenden Schule, sichtlich selbstbewusster als früher. | Selfie | Positiv |
| 41 | Mai | Partnerschaft | Ein Selfie von Mara in ihrer Physiotherapiepraxis in neuer leitender Rolle. | Selfie | Positiv |
| 41 | Juni | Alltag | Ein Selfie von Jonas beim Wäscheaufhängen mit Emil, der ihm dabei hilft. | Selfie | Neutral |
| 41 | Juli | Reisen | Ein Selfie aus einer Berghütte zeigt die durchnässte, aber lachende Familie, die sich vor einem plötzlichen Unwetter in Südtirol in Sicherheit gebracht hat. | Selfie | Sehr positiv |
| 41 | August | Freundschaft | Ein Selfie von David, allein auf einer Parkbank, das er Jonas mit der Nachricht "können wir reden?" schickt. | Selfie | Negativ |
| 41 | September | Persönliche Entwicklung | Ein Selfie von Jonas und David bei einem langen Spaziergang, bei dem Jonas vor allem zuhört. | Selfie | Positiv |
| 41 | Oktober | Finanzen | Ein Selfie mit den Unterlagen seiner neuen privaten Altersvorsorge auf dem Tisch. | Selfie | Neutral |
| 41 | November | Freizeit | Ein Besucher fotografiert Jonas vor seiner Fotoserie über Köln im kleinen Kulturzentrum. | Fremdaufnahme | Sehr positiv |
| 41 | Dezember | Familie | Ein Selfie von Karin, die erstmals mehrere Tage bei Jonas und Mara verbringt, mit Emil auf dem Sofa. | Selfie | Positiv |
## Alter 42
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 42 | Januar | Beruf | Ein Selfie am Schreibtisch zeigt Jonas beim Absenden einer Absage an einen lukrativen, aber zeitraubenden Auftrag. | Selfie | Positiv |
| 42 | Februar | Familie | Ein Selfie-Versuch mit Emil misslingt, weil er sich wegdreht – ein kleines Symbol für seine wachsende Distanz. | Selfie | Negativ |
| 42 | März | Persönliche Entwicklung | Ein Selfie von Jonas vor Emils geschlossener Zimmertür, mit dem er sich selbst zum Anklopfen statt Reingehen erinnert. | Selfie | Positiv |
| 42 | April | Finanzen | Ein Selfie mit dem Steuerbescheid in der Hand zeigt Jonas' besorgten Blick. | Selfie | Negativ |
| 42 | Mai | Selbstständigkeit | Ein Selfie mit seinem neuen Steuerberater nach dem ersten gemeinsamen Termin. | Selfie | Positiv |
| 42 | Juni | Partnerschaft | Ein Kellner fotografiert Jonas und Mara vor dem Eiffelturm bei ihrem Wochenende in Paris. | Fremdaufnahme | Sehr positiv |
| 42 | Juli | Familie | Ein Selfie von Jonas, Lea und Karin vor Leas neuem Haus in der Nähe. | Selfie | Positiv |
| 42 | August | Freizeit | Ein Selfie von Jonas und David in Radtrikots vor ihrer ersten gemeinsamen Rennradtour. | Selfie | Positiv |
| 42 | September | Beruf | Ein Selfie mit seiner scheidenden Mitarbeiterin, die ihm ihr neues Vertragsangebot zeigt. | Selfie | Neutral |
| 42 | Oktober | Persönliche Entwicklung | Ein Selfie im Wartezimmer zeigt Jonas' angespannten Blick vor dem Kontrolltermin wegen des auffälligen Blutwerts. | Selfie | Negativ |
| 42 | November | Familie | Ein Selfie direkt nach einem lauten Streit zeigt Emil mit rotem Gesicht und abgewandtem Blick. | Selfie | Negativ |
| 42 | Dezember | Familie | Ein Selfie von Jonas und Emil, versöhnt und nebeneinander grinsend, nach seiner Entschuldigung. | Selfie | Positiv |
## Alter 43
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 43 | Januar | Beruf | Ein Selfie mit dem unterschriebenen mehrjährigen Beratungsvertrag in der Hand. | Selfie | Sehr positiv |
| 43 | Februar | Wohnen | Ein Selfie von Jonas und Mara vor einem Immobilienportal, diesmal entspannter als beim letzten Mal. | Selfie | Neutral |
| 43 | März | Wohnen | Ein Makler fotografiert Jonas und Mara vor einem besichtigten Haus, beide mit unentschlossenem Gesichtsausdruck. | Fremdaufnahme | Neutral |
| 43 | April | Persönliche Entscheidung | Ein Selfie von Jonas und Mara vor ihrer eigenen Wohnungstür, zufrieden mit ihrer Entscheidung dagegen. | Selfie | Positiv |
| 43 | Mai | Familie | Ein Selfie von Karin mit ihrem neuen Partner beim ersten gemeinsamen Spaziergang. | Selfie | Positiv |
| 43 | Juni | Familie | Ein Selfie von Jonas, anfangs mit sichtlich gemischtem Gesichtsausdruck neben Karins neuem Partner. | Selfie | Neutral |
| 43 | Juli | Reisen | Ein Selfie von Jonas und Emil auf einem Berggipfel bei ihrer ersten gemeinsamen Vater-Sohn-Wanderreise. | Selfie | Sehr positiv |
| 43 | August | Familie | Ein Selfie am Lagerfeuer zeigt Jonas und Emil im vertrauten Gespräch über erste Verliebtheit. | Selfie | Positiv |
| 43 | September | Beruf | Ein Foto vom Rednerpult zeigt Jonas bei seinem ersten Fachvortrag. | Fremdaufnahme | Positiv |
| 43 | Oktober | Freizeit | Ein Selfie mit einer Gruppe junger Fotografen beim ehrenamtlichen Workshop. | Selfie | Positiv |
| 43 | November | Freundschaft | Ein Selfie von David mit seiner Frau, wieder Arm in Arm, das er Jonas stolz schickt. | Selfie | Positiv |
| 43 | Dezember | Familie | Ein Familienfoto zeigt Karins neuen Partner erstmals selbstverständlich mit am Weihnachtstisch. | Fremdaufnahme | Positiv |
## Alter 44
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 44 | Januar | Selbstständigkeit | Ein Selfie mit seiner neuen Teilzeit-Ingenieurin am gemeinsamen Schreibtisch. | Selfie | Positiv |
| 44 | Februar | Beruf | Ein Selfie mit einem Stapel Personalunterlagen zeigt Jonas' überraschten Blick über den Mehraufwand. | Selfie | Negativ |
| 44 | März | Familie | Ein Selfie von Emil vor einer Wandtafel mit ausgeschnittenen Berufsbildern. | Selfie | Neutral |
| 44 | April | Familie | Ein Selfie von Jonas und Emil beim offenen Gespräch über dessen Zukunftspläne. | Selfie | Positiv |
| 44 | Mai | Partnerschaft | Ein Selfie von Mara vor ihrer Praxis mit dem Beteiligungsangebot in der Hand. | Selfie | Positiv |
| 44 | Juni | Finanzen | Ein Selfie von Jonas und Mara über ausgebreiteten Finanzunterlagen am Küchentisch. | Selfie | Neutral |
| 44 | Juli | Partnerschaft | Ein Selfie von Mara vor ihrer Praxis, nun als Mitinhaberin, Jonas applaudierend daneben. | Selfie | Positiv |
| 44 | August | Reisen | Ein Strandfotograf macht ein Foto der Familie am Atlantikstrand in Frankreich. | Fremdaufnahme | Positiv |
| 44 | September | Freundschaft | Ein altes Selfie mit Cem, das Jonas beim Durchsehen seines Fotoarchivs findet und wehmütig betrachtet. | Selfie | Neutral |
| 44 | Oktober | Familie | Ein Selfie von Karin mit Koffer, bereit für ihren ersten Winter in Spanien. | Selfie | Positiv |
| 44 | November | Persönliche Entwicklung | Ein Selfie von Jonas nach einem Telefonat mit Karin, deutlich entspannter als frühere ähnliche Fotos. | Selfie | Positiv |
| 44 | Dezember | Beruf | Ein Selfie mit der Jahresbilanz-Tabelle auf dem Bildschirm zeigt Jonas' zufriedenes Grinsen. | Selfie | Sehr positiv |
## Alter 45
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 45 | Januar | Persönliche Entwicklung | Ein Selfie zu seinem 45. Geburtstag zeigt Jonas ruhiger und zufriedener als auf dem Foto zum 40. | Selfie | Positiv |
| 45 | Februar | Beruf | Ein Selfie mit seiner Mitarbeiterin, der er symbolisch einen Aktenordner übergibt. | Selfie | Positiv |
| 45 | März | Familie | Ein Selfie von Emil im Blaumann nach seinem ersten Schülerpraktikum. | Selfie | Positiv |
| 45 | April | Partnerschaft | Ein Selfie von Jonas und Mara beim Planen eines gemeinsamen Wochenendes ohne Kind. | Selfie | Positiv |
| 45 | Mai | Reisen | Ein Selfie mit den gebuchten Flugtickets für ihre Reise zu zweit im Herbst. | Selfie | Positiv |
| 45 | Juni | Freundschaft | Ein Selfie von Jonas, David und zwei alten Freunden bei ihrem ersten gemeinsamen Wochenende seit Jahren. | Selfie | Sehr positiv |
| 45 | Juli | Familie | Ein Familienfoto zeigt Karin mit beiden Kindern, Enkeln und ihrem Partner an ihrem Geburtstagstisch. | Fremdaufnahme | Positiv |
| 45 | August | Freizeit | Ein Foto von der Buchpräsentation zeigt Jonas mit seinem frisch gedruckten Bildband über zwanzig Jahre Köln. | Fremdaufnahme | Sehr positiv |
| 45 | September | Beruf | Ein Selfie nach dem letzten Termin mit einem schwierigen Kunden zeigt Jonas erleichtert statt enttäuscht. | Selfie | Neutral |
| 45 | Oktober | Partnerschaft | Ein Passant fotografiert Jonas und Mara vor der Steilküste in Nordspanien. | Fremdaufnahme | Sehr positiv |
| 45 | November | Persönliche Entwicklung | Ein Selfie beim Herbstspaziergang zeigt Jonas sichtlich gelassener als auf früheren Fotos in ähnlicher Umgebung. | Selfie | Positiv |
| 45 | Dezember | Familie | Ein Familienfoto zum Jahresende zeigt Jonas, Mara und Emil entspannt auf dem Sofa – ein stiller Jahresrückblick in einem einzigen Bild. | Fremdaufnahme | Sehr positiv |

View file

@ -0,0 +1,356 @@
## Alter 25
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 25 | Januar | Beruf | Eine Kollegin fotografiert Sophie, wie sie die E-Mail zur Vertragsverlängerung freudig ihrer Teamrunde zeigt. | Fremdaufnahme | Positiv |
| 25 | Februar | Wohnen | Ein Selfie von Sophie und Alina in der gemeinsamen WG-Küche entsteht kurz nachdem Alina ihren geplanten Auszug angekündigt hat. | Selfie | Neutral |
| 25 | März | Liebe | Ein Selfie von Sophie und Felix am Küchentisch, umgeben von Wohnungsanzeigen, hält ihr erstes ernsthaftes Gespräch über Zusammenziehen fest. | Selfie | Neutral |
| 25 | April | Beruf | Ein Besucher fotografiert Sophie, wie sie die von ihr organisierte Lesungsreihe eröffnet. | Fremdaufnahme | Positiv |
| 25 | Mai | Alltag | Ein Selfie mit ihrem neu gekauften, deutlich günstigeren Gebrauchtfahrrad zeigt Sophie mit gemischten Gefühlen. | Selfie | Negativ |
| 25 | Juni | Freundschaft | Alina fotografiert Sophie mit der letzten Umzugskiste in der leeren WG-Küche, sichtlich gerührt. | Fremdaufnahme | Negativ |
| 25 | Juli | Wohnen | Ein Selfie vor ihrer neuen eigenen Wohnungstür zeigt Sophie mit dem Schlüssel in der Hand. | Selfie | Positiv |
| 25 | August | Liebe | Ein Selfie vom gemeinsamen Abendessen wirkt harmonisch, entstand aber kurz nach Felix' enttäuschter Reaktion auf ihre Entscheidung. | Selfie | Negativ |
| 25 | September | Beruf | Ein Kollege fotografiert Sophie bei einem Pressegespräch zum großen Kulturfestival. | Fremdaufnahme | Positiv |
| 25 | Oktober | Freizeit | Ein Teilnehmer macht ein Gruppenfoto vom ersten Abend ihres neuen Literaturkreises. | Fremdaufnahme | Positiv |
| 25 | November | Familie | Ihr Bruder Daniel fotografiert Sophie beim Spielen mit seiner kleinen Tochter. | Fremdaufnahme | Positiv |
| 25 | Dezember | Liebe | Ein Selfie von Sophie bei ihren Eltern in Erfurt zeigt sie entspannt, während Felix zeitgleich bei seiner eigenen Familie ist. | Selfie | Neutral |
## Alter 26
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 26 | Januar | Liebe | Ein Selfie von einem langen Spaziergang am Auensee hält den Abend fest, an dem beide offen über ihre unterschiedlichen Zukunftsvorstellungen sprechen. | Selfie | Negativ |
| 26 | Februar | Beruf | Ein Teamfoto entsteht bei der Bekanntgabe der wichtigen Förderung für das Kulturfestival. | Fremdaufnahme | Sehr positiv |
| 26 | März | Alltag | Ein Selfie mit ihrem ersten gefüllten Sparglas zeigt Sophie zufrieden nach der kleinen Gehaltserhöhung. | Selfie | Positiv |
| 26 | April | Freundschaft | Sophie und Nina machen nach dem Literaturkreis ihr erstes gemeinsames Foto. | Selfie | Positiv |
| 26 | Mai | Liebe | Ein Selfie von einem Solo-Abend zeigt Sophie entspannt mit Wein und Buch auf dem Sofa, während Felix auf Tournee ist. | Selfie | Neutral |
| 26 | Juni | Beruf | Kollegen gratulieren mit einem Foto, als Sophie ihren unbefristeten Vertrag unterschreibt. | Fremdaufnahme | Sehr positiv |
| 26 | Juli | Reisen | Ein Selfie vor der Karlsbrücke zeigt Sophie und Alina auf ihrer gemeinsamen Reise nach Prag. | Selfie | Positiv |
| 26 | August | Liebe | Ein Selfie vom gemeinsamen Abendessen wirkt unbeschwert, doch beide spüren bereits, wie mühsam sich das Zusammenleben nach Felix' Rückkehr anfühlt. | Selfie | Negativ |
| 26 | September | Persönliche Entwicklung | Ein altes Selfie mit Felix aus den Anfangsjahren, das Sophie nachdenklich durchscrollt, lässt sie fragen, ob sie nur aus Gewohnheit festhält. | Selfie | Neutral |
| 26 | Oktober | Familie | Ein Screenshot eines Videotelefonats zeigt Sophie und ihre Mutter Petra im aufmunternden Gespräch. | Selfie | Positiv |
| 26 | November | Liebe | Ein letztes gemeinsames Selfie vor der vereinbarten Auszeit wirkt bewusst distanziert. | Selfie | Negativ |
| 26 | Dezember | Liebe | Ein Familienfoto bei Weihnachten wirkt normal, verdeckt aber die wachsende Distanz zwischen Sophie und Felix. | Fremdaufnahme | Negativ |
## Alter 27
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 27 | Januar | Liebe | Ein letztes gemeinsames Selfie, aufgenommen direkt nach dem einvernehmlichen Trennungsgespräch, zeigt beide sichtlich erleichtert und traurig zugleich. | Selfie | Sehr negativ |
| 27 | Februar | Freundschaft | Nina fotografiert einen Mädelsabend mit Wein und Trostschokolade, mit dem Alina und Nina Sophie durch die erste Zeit nach der Trennung helfen. | Fremdaufnahme | Positiv |
| 27 | März | Alltag | Ein Selfie zeigt Sophie neben einer Kiste mit Dingen, die sie an Felix erinnern, bereit für den Wertstoffhof. | Selfie | Neutral |
| 27 | April | Beruf | Ein Kollege fotografiert Sophie spätabends noch allein im Büro. | Fremdaufnahme | Neutral |
| 27 | Mai | Persönliche Entwicklung | Ein Selfie vor einem Wandkalender zeigt Sophie, wie sie spontan ein Wochenende nur für sich einträgt. | Selfie | Positiv |
| 27 | Juni | Reisen | Ein Selfie vor der Kathedrale von Lissabon zeigt Sophie strahlend auf ihrer ersten Solo-Reise. | Selfie | Sehr positiv |
| 27 | Juli | Freundschaft | Ein Video-Chat-Screenshot mit Felix wirkt entspannt und unbeschwert. | Selfie | Positiv |
| 27 | August | Weiterbildung | Ein Kursfoto vom ersten Abend ihrer Weiterbildung im digitalen Kulturmanagement zeigt Sophie in der Gruppe. | Fremdaufnahme | Positiv |
| 27 | September | Beruf | Ein Foto vom leeren Veranstaltungsraum, den das abgesagte Projekt genutzt hätte, zeigt Sophie sichtlich enttäuscht davor. | Fremdaufnahme | Negativ |
| 27 | Oktober | Alltag | Eine Standbetreiberin fotografiert Sophie beim Stöbern nach gebrauchten Fachbüchern auf dem Bücherflohmarkt. | Fremdaufnahme | Neutral |
| 27 | November | Familie | Daniel fotografiert Sophie beim Spielen mit seiner Tochter während ihres langen Wochenendbesuchs. | Fremdaufnahme | Positiv |
| 27 | Dezember | Persönliche Entwicklung | Ein Familienfoto zu Weihnachten zeigt Sophie entspannt lachend, ohne den Blick auf die Vergangenheit gerichtet. | Fremdaufnahme | Positiv |
## Alter 28
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 28 | Januar | Beruf | Ein Teamfoto entsteht bei Sophies Beförderung zur Projektkoordinatorin. | Fremdaufnahme | Sehr positiv |
| 28 | Februar | Weiterbildung | Ein Selfie zeigt Sophie übermüdet zwischen Lernkarten und leeren Kaffeetassen. | Selfie | Negativ |
| 28 | März | Freundschaft | Ein Selfie von Sophie und Nina bei einem ihrer immer häufigeren Treffen zu zweit. | Selfie | Positiv |
| 28 | April | Weiterbildung | Ein Foto von der Zertifikatsübergabe zeigt Sophie stolz mit ihrem Abschluss. | Fremdaufnahme | Sehr positiv |
| 28 | Mai | Liebe | Ein Konzert-Selfie von Sophie und Nina zeigt einen Blick zwischen beiden, der mehr als Freundschaft andeutet. | Selfie | Neutral |
| 28 | Juni | Persönliche Entwicklung | Ein Selfie vor dem Spiegel an einem grüblerischen Abend allein. | Selfie | Neutral |
| 28 | Juli | Liebe | Ein Selfie hält den Moment fest, in dem Nina Sophie gesteht, sich in sie verliebt zu haben. | Selfie | Sehr positiv |
| 28 | August | Liebe | Ein erstes gemeinsames Pärchen-Selfie von Sophie und Nina. | Selfie | Sehr positiv |
| 28 | September | Freundschaft | Ein Selfie im Vergleich zu einem alten Freundschaftsfoto zeigt beide noch etwas unsicher in der neuen Rolle. | Selfie | Neutral |
| 28 | Oktober | Familie | Ein Foto vom Abendessen bei Sophies Eltern zeigt alle entspannt lachend, nachdem sie ihnen von Nina erzählt hat. | Fremdaufnahme | Positiv |
| 28 | November | Beruf | Ein Foto von der Besprechung, in der Sophie das neue Kulturprojekt angeboten wird. | Fremdaufnahme | Positiv |
| 28 | Dezember | Liebe | Ein Familienfoto zu Weihnachten in Erfurt zeigt Nina erstmals mitten in der Familie. | Fremdaufnahme | Sehr positiv |
## Alter 29
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 29 | Januar | Beruf | Ein Team-Kickoff-Foto zeigt Sophie als neue Leiterin des Kulturprojekts. | Fremdaufnahme | Positiv |
| 29 | Februar | Beruf | Ein Selfie am übervollen Schreibtisch zeigt Sophie erschöpft zwischen Aktenordnern und Budgettabellen. | Selfie | Negativ |
| 29 | März | Liebe | Ein Passant fotografiert Sophie und Nina vor Nyhavn während ihres Kopenhagen-Wochenendes. | Fremdaufnahme | Positiv |
| 29 | April | Freizeit | Ein Selfie zeigt Sophie stolz mit einer gebrauchten Kamera, gekauft von ihrem ersten gesparten Geld. | Selfie | Positiv |
| 29 | Mai | Freundschaft | Ein Selfie von Sophie und Alina mit dem Ultraschallbild in der Hand feiert Alinas Schwangerschaft. | Selfie | Sehr positiv |
| 29 | Juni | Beruf | Ein Selfie zeigt Sophie mit besorgtem Blick nach dem Telefonat, in dem sich ein Kooperationspartner zurückzieht. | Selfie | Negativ |
| 29 | Juli | Beruf | Ein Handschlag-Foto mit dem neu gefundenen Kooperationspartner. | Fremdaufnahme | Positiv |
| 29 | August | Partnerschaft | Ein Selfie vom Küchentisch hält den Abend fest, an dem Nina erstmals eine gemeinsame Wohnung anspricht. | Selfie | Positiv |
| 29 | September | Wohnen | Ein Selfie beim gemeinsamen Frühstück zeigt beide, jede noch mit eigenem Wohnungsschlüssel gut sichtbar auf dem Tisch. | Selfie | Neutral |
| 29 | Oktober | Familie | Ein Foto von der Taufe zeigt Sophie als frischgebackene Patentante mit Baby Paul im Arm. | Fremdaufnahme | Sehr positiv |
| 29 | November | Freizeit | Ein Selfie mit tonverschmierten Händen im gemeinsamen Keramikkurs. | Selfie | Positiv |
| 29 | Dezember | Beruf | Ein Jubel-Foto des Teams entsteht bei der Zusage zur Weiterfinanzierung des Kulturprojekts. | Fremdaufnahme | Sehr positiv |
## Alter 30
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 30 | Januar | Persönliche Entwicklung | Ein Foto vom Geburtstagsfest mit Freunden zeigt Sophie zu ihrem 30. Geburtstag entspannt lachend. | Fremdaufnahme | Positiv |
| 30 | Februar | Wohnen | Ein Selfie von Sophie und Nina vor einem Wohnungsanzeigen-Aushang markiert den Beginn der Suche. | Selfie | Positiv |
| 30 | März | Wohnen | Ein Selfie nach einer enttäuschenden Wohnungsbesichtigung zeigt beide sichtlich frustriert vor einer zu teuren Altbauwohnung. | Selfie | Negativ |
| 30 | April | Wohnen | Ein Selfie vor der Tür der neuen, bezahlbaren Altbauwohnung. | Selfie | Sehr positiv |
| 30 | Mai | Wohnen | Ein Selfie beim Kistenauspacken zeigt Sophie und Nina umgeben von halb ausgepackten Umzugskartons. | Selfie | Sehr positiv |
| 30 | Juni | Partnerschaft | Ein Selfie vom gemeinsamen Abendessen wirkt entspannt, entstand jedoch inmitten wiederkehrender Diskussionen über gemeinsame und getrennte Freizeit. | Selfie | Negativ |
| 30 | Juli | Partnerschaft | Ein Selfie zeigt beide lachend mit ihren jeweiligen Kalendern, in denen feste Zeiten für eigene Hobbys eingetragen sind. | Selfie | Positiv |
| 30 | August | Beruf | Ein Foto am Schreibtisch der erkrankten Geschäftsführerin zeigt Sophie in Vertretungsfunktion. | Fremdaufnahme | Positiv |
| 30 | September | Beruf | Ein Foto von einer Strategiesitzung zeigt Sophie, wie sie sich engagiert zu Wort meldet. | Fremdaufnahme | Positiv |
| 30 | Oktober | Familie | Ein Foto ihres Vaters Michael im Vereinstrikot seines neuen Ehrenamts, das er Sophie schickt. | Fremdaufnahme | Neutral |
| 30 | November | Alltag | Ein Selfie vor dem Bankautomaten nach der Eröffnung ihres gemeinsamen Kontos. | Selfie | Neutral |
| 30 | Dezember | Liebe | Ein Selfie vor ihrem ersten gemeinsamen Weihnachtsbaum in der eigenen Wohnung. | Selfie | Sehr positiv |
## Alter 31
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 31 | Januar | Beruf | Ein Teamfoto zur Ernennung von Sophie zur stellvertretenden Geschäftsführerin. | Fremdaufnahme | Sehr positiv |
| 31 | Februar | Beruf | Ein Foto von einer langen Sitzung zeigt Sophie mit einem hohen Aktenstapel vor sich. | Fremdaufnahme | Neutral |
| 31 | März | Familie | Ein Videocall-Screenshot mit ihrer Mutter Petra, die von ihrer geplanten Arbeitszeitreduzierung erzählt. | Selfie | Neutral |
| 31 | April | Partnerschaft | Ein Selfie vom Küchentisch hält den Abend fest, an dem Nina erstmals konkret über einen späteren Kinderwunsch spricht. | Selfie | Neutral |
| 31 | Mai | Persönliche Entwicklung | Ein Selfie vor einem Schaufenster mit Kinderwagen zeigt Sophies nachdenklichen Blick. | Selfie | Neutral |
| 31 | Juni | Partnerschaft | Ein Selfie von einem entspannten Spaziergang zeigt beide einig, das Thema nicht unter Zeitdruck zu setzen. | Selfie | Positiv |
| 31 | Juli | Reisen | Ein Gruppenfoto am Strand zeigt Sophie, Nina und zwei Freunde auf ihrer zweiwöchigen Griechenland-Reise. | Fremdaufnahme | Sehr positiv |
| 31 | August | Beruf | Ein Selfie am überfüllten Schreibtisch zeigt Sophie erschöpft, nachdem eine Kollegin überraschend gekündigt hat. | Selfie | Negativ |
| 31 | September | Freundschaft | Ein Foto vor dem beladenen Umzugsauto zeigt Sophie und Alina beim letzten spontanen Treffen vor Alinas Umzug an den Stadtrand. | Selfie | Neutral |
| 31 | Oktober | Freizeit | Ein gemütliches Selfie mit Buch und Tee zeigt Sophie beim Wiederentdecken des Lesens für sich selbst. | Selfie | Positiv |
| 31 | November | Partnerschaft | Ein Passant fotografiert den Moment, in dem Nina Sophie während ihres Wochenendausflugs einen Antrag macht. | Fremdaufnahme | Sehr positiv |
| 31 | Dezember | Liebe | Ein Selfie mit den neuen Verlobungsringen zeigt beide strahlend. | Selfie | Positiv |
## Alter 32
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 32 | Januar | Hochzeit | Ein Selfie von Sophie und Nina über aufgeschlagenen Hochzeitsmagazinen. | Selfie | Positiv |
| 32 | Februar | Familie | Ein Videocall-Screenshot mit Daniel, der anbietet, bei der Hochzeitsorganisation zu helfen. | Selfie | Positiv |
| 32 | März | Beruf | Ein Foto von der Krisensitzung zeigt Sophie angespannt nach der Kürzung einer wichtigen Förderung. | Fremdaufnahme | Negativ |
| 32 | April | Beruf | Ein erleichtertes Teamfoto entsteht, nachdem Sophie durch Umplanung Entlassungen verhindern kann. | Fremdaufnahme | Sehr positiv |
| 32 | Mai | Partnerschaft | Ein Selfie zeigt Nina allein mit Hochzeitskatalogen am Küchentisch, während Sophie im Hintergrund am Laptop arbeitet. | Selfie | Negativ |
| 32 | Juni | Hochzeit | Ein Selfie von Sophie und Nina, versöhnt lächelnd, mit der deutlich reduzierten Gästeliste in der Hand. | Selfie | Positiv |
| 32 | Juli | Familie | Der Hochzeitsfotograf hält den Moment der freien Zeremonie von Sophie und Nina mit Familie und Freunden fest. | Fremdaufnahme | Sehr positiv |
| 32 | August | Reisen | Ein Selfie im Zugabteil zeigt beide auf ihrer Hochzeitsreise durch Südfrankreich und Nordspanien. | Selfie | Sehr positiv |
| 32 | September | Alltag | Ein Selfie von einem gewöhnlichen Sofa-Abend zeigt beide bewusst unspektakulär entspannt. | Selfie | Neutral |
| 32 | Oktober | Familie | Ein Selfie vom Abendgespräch, in dem Nina das Thema Kinder erneut mit konkreteren Vorstellungen anspricht. | Selfie | Neutral |
| 32 | November | Partnerschaft | Ein Selfie vor einem Kinderzimmer-Möbelgeschäft zeigt Sophie nachdenklich, aber offener als zuvor. | Selfie | Positiv |
| 32 | Dezember | Persönliche Entscheidung | Ein Foto aus dem Wartezimmer einer Kinderwunschberatungsstelle zeigt beide vor ihrem ersten Infogespräch. | Fremdaufnahme | Positiv |
## Alter 33
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 33 | Januar | Familie | Ein Foto im Wartezimmer der Kinderwunschberatung zeigt Sophie und Nina bei ihrem ersten gemeinsamen Termin. | Fremdaufnahme | Neutral |
| 33 | Februar | Partnerschaft | Ein Selfie zeigt beide über Formularen und Broschüren zur Familiengründung am Küchentisch. | Selfie | Negativ |
| 33 | März | Beruf | Ein Foto vom Gespräch mit dem Vorstand zeigt Sophie beim Angebot, die Geschäftsführung zu übernehmen. | Fremdaufnahme | Sehr positiv |
| 33 | April | Persönliche Entscheidung | Ein Handschlag-Foto mit dem Vorstand besiegelt ihre Zusage mit schrittweisem Übergang. | Fremdaufnahme | Positiv |
| 33 | Mai | Familie | Ein Selfie von Sophie und Nina, entschlossen lächelnd, nach der Entscheidung, den Kinderwunsch weiterzuverfolgen. | Selfie | Positiv |
| 33 | Juni | Finanzen | Ein Selfie mit einem neuen, beschrifteten Sparordner "Familienplanung" auf dem Küchentisch. | Selfie | Neutral |
| 33 | Juli | Freundschaft | Ein Kaffee-Selfie von Sophie und Alina zeigt Alina, wie sie zuhört, ohne zu einer Entscheidung zu drängen. | Selfie | Positiv |
| 33 | August | Beruf | Ein Foto am Schreibtisch zeigt Sophie mit geöffneten Budgettabellen bei der Jahresplanung. | Fremdaufnahme | Neutral |
| 33 | September | Familie | Ein Selfie von einem stillen Abend zu zweit, kurz nachdem der erste Versuch nicht geklappt hat. | Selfie | Negativ |
| 33 | Oktober | Partnerschaft | Ein Selfie beim spontanen Kinoabend zeigt beide bewusst ohne das Thema Familienplanung. | Selfie | Positiv |
| 33 | November | Freizeit | Ein Kellner fotografiert Sophie und Nina vor dem Stephansdom bei ihrem spontanen Wien-Wochenende. | Fremdaufnahme | Positiv |
| 33 | Dezember | Familie | Ein Foto vom Gespräch mit Petra und Michael zeigt deren unterstützende Reaktion auf die Pläne des Paares. | Fremdaufnahme | Positiv |
## Alter 34
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 34 | Januar | Familie | Ein Selfie zeigt beide mit einem Kalender, in dem sie den nächsten Versuch markiert haben. | Selfie | Neutral |
| 34 | Februar | Beruf | Ein Foto von der Vorstandssitzung zeigt Sophie zunehmend in strategische Entscheidungen eingebunden. | Fremdaufnahme | Positiv |
| 34 | März | Familie | Ein Selfie mit dem positiven Schwangerschaftstest zeigt Ninas und Sophies überglückliche Gesichter. | Selfie | Sehr positiv |
| 34 | April | Partnerschaft | Ein Selfie am Küchentisch zeigt beide zwischen Freude und Sorge um den Verlauf der Schwangerschaft schwankend. | Selfie | Positiv |
| 34 | Mai | Familie | Ein Selfie aus dem Wartezimmer der Frauenärztin zeigt Sophie mit dem Ultraschallbild in der Hand. | Selfie | Sehr positiv |
| 34 | Juni | Wohnen | Ein Selfie im halb eingerichteten Kinderzimmer zeigt Sophie beim Aufbauen des Kinderbetts. | Selfie | Positiv |
| 34 | Juli | Beruf | Ein Foto vom Gespräch, in dem Sophie ein zusätzliches Großprojekt ablehnt, um ihre Belastung zu begrenzen. | Fremdaufnahme | Positiv |
| 34 | August | Familie | Ein Foto beim Übergeben mehrerer Kisten mit Babyausstattung zeigt Alina und Sophie lachend. | Fremdaufnahme | Positiv |
| 34 | September | Alltag | Ein Selfie mit einem handgeschriebenen Haushaltsplan an der Pinnwand für die Zeit nach der Geburt. | Selfie | Neutral |
| 34 | Oktober | Familie | Ein Selfie beim Kochen zeigt Sophie, während die hochschwangere Nina sich auf dem Sofa ausruht. | Selfie | Neutral |
| 34 | November | Partnerschaft | Ein Selfie vom gemeinsamen Abendessen wirkt ruhig, entstand aber nach einem angespannten Gespräch über Verantwortung und Arbeitszeiten. | Selfie | Negativ |
| 34 | Dezember | Familie | Ein Selfie vor dem Weihnachtsbaum zeigt die hochschwangere Nina und Sophie, ruhig und erwartungsvoll. | Selfie | Positiv |
## Alter 35
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 35 | Januar | Familie | Das Krankenhauspersonal fotografiert Sophie mit ihrer neugeborenen Tochter Clara im Arm. | Fremdaufnahme | Sehr positiv |
| 35 | Februar | Familie | Ein übermüdetes nächtliches Selfie zeigt Sophie mit Clara während der Elternzeit. | Selfie | Negativ |
| 35 | März | Familie | Ein Selfie am Fenster im Morgenlicht zeigt Sophie zunehmend sicher mit Clara auf dem Arm. | Selfie | Positiv |
| 35 | April | Partnerschaft | Ein Selfie vom Frühstückstisch wirkt normal, entstand aber am Morgen nach einem heftigen Streit über die Aufgabenverteilung. | Selfie | Negativ |
| 35 | Mai | Partnerschaft | Ein Selfie zeigt beide vor einer neu aufgehängten Wochenplan-Tafel. | Selfie | Positiv |
| 35 | Juni | Familie | Ein Foto von Petra mit Clara im Park zeigt die Großeltern erstmals einen ganzen Nachmittag allein mit ihr. | Fremdaufnahme | Positiv |
| 35 | Juli | Freundschaft | Ein altes Gruppenfoto mit Freunden, das Sophie durchscrollt, lässt sie merken, wie lange das letzte Treffen her ist. | Selfie | Neutral |
| 35 | August | Persönliche Entwicklung | Ein Selfie im Café mit Buch zeigt Sophie bei einem kleinen Freiraum für sich selbst. | Selfie | Positiv |
| 35 | September | Beruf | Ein Selfie zeigt Sophie mit Clara auf dem Arm beim Lesen einer Vereins-Rundmail auf dem Handy. | Selfie | Neutral |
| 35 | Oktober | Familie | Ein Video-Standbild von Clara beim ersten Krabbeln. | Fremdaufnahme | Positiv |
| 35 | November | Partnerschaft | Ein Kellner fotografiert Sophie und Nina beim ersten Abendessen ohne Kind seit der Geburt. | Fremdaufnahme | Sehr positiv |
| 35 | Dezember | Familie | Ein Familienfoto am Weihnachtsbaum zeigt Claras erstes, lebhaftes Weihnachtsfest. | Fremdaufnahme | Sehr positiv |
## Alter 36
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 36 | Januar | Beruf | Ein Foto am ersten Arbeitstag zurück im Büro zeigt Sophie mit reduzierter Stundenzahl. | Fremdaufnahme | Neutral |
| 36 | Februar | Familie | Die Erzieherin fotografiert Clara und Sophie am ersten Tag der Kita-Eingewöhnung. | Fremdaufnahme | Neutral |
| 36 | März | Beruf | Ein Selfie am Schreibtisch zeigt Sophie mit einem Foto von Clara als Bildschirmhintergrund gut sichtbar. | Selfie | Neutral |
| 36 | April | Partnerschaft | Ein Selfie zeigt Nina, wie sie Sophie stolz ihren neuen Teilzeitvertrag zeigt. | Selfie | Positiv |
| 36 | Mai | Beruf | Ein Teamfoto zur offiziellen Ernennung von Sophie als Geschäftsführerin. | Fremdaufnahme | Sehr positiv |
| 36 | Juni | Beruf | Ein erschöpftes Selfie am Schreibtisch zeigt einen Kalender voller Termine trotz reduzierter Arbeitszeit. | Selfie | Negativ |
| 36 | Juli | Familie | Ein Selfie im Park mit Clara zeigt Sophie an einem Nachmittag, den sie bewusst nicht im Büro verbringt. | Selfie | Positiv |
| 36 | August | Reisen | Ein Foto am Ostseestrand zeigt die Familie bei ihrer ersten längeren Reise mit Clara. | Fremdaufnahme | Positiv |
| 36 | September | Familie | Ein Video-Standbild von Clara bei ihren ersten verständlichen Wörtern. | Fremdaufnahme | Sehr positiv |
| 36 | Oktober | Beruf | Ein Teamfoto entsteht bei der Vorstellung der neuen flexiblen Arbeitszeitmodelle. | Fremdaufnahme | Positiv |
| 36 | November | Alltag | Ein Selfie mit Kita-Rechnung und Haushaltsbuch auf dem Küchentisch. | Selfie | Neutral |
| 36 | Dezember | Partnerschaft | Ein Selfie zeigt Sophie und Nina mit Clara zwischen sich, zufrieden als eingespieltes Elternteam. | Selfie | Positiv |
## Alter 37
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 37 | Januar | Beruf | Ein Foto von der Strategie-Klausur zeigt Sophie mit ihrem Team an der Pinnwand. | Fremdaufnahme | Positiv |
| 37 | Februar | Familie | Ein Selfie zeigt Sophie mit der kranken Clara auf dem Sofa, Fieberthermometer sichtbar. | Selfie | Negativ |
| 37 | März | Beruf | Ein Foto vom Team nach der Ablehnung des wichtigen Förderantrags zeigt enttäuschte Gesichter. | Fremdaufnahme | Negativ |
| 37 | April | Beruf | Ein Foto von der überarbeiteten Projektplanung an der Wand zeigt Sophie erleichtert über den geretteten Kern des Programms. | Fremdaufnahme | Neutral |
| 37 | Mai | Partnerschaft | Ein Selfie vom gemeinsamen Abendessen zeigt Sophie mit Handy in der Hand, Nina daneben sichtlich genervt. | Selfie | Negativ |
| 37 | Juni | Partnerschaft | Ein Selfie zeigt das Handy demonstrativ im Schrank verstaut, beide lachend über die neue Regelung. | Selfie | Positiv |
| 37 | Juli | Freundschaft | Ein Selfie von Sophie und Alina bei ihrem ersten Wochenende nur zu zweit seit Jahren. | Selfie | Sehr positiv |
| 37 | August | Familie | Die Erzieherin fotografiert Clara mit ihren neuen Kindergartenfreunden. | Fremdaufnahme | Positiv |
| 37 | September | Beruf | Ein Foto von der Vertragsunterzeichnung mit der neuen Stiftung als Förderpartner. | Fremdaufnahme | Sehr positiv |
| 37 | Oktober | Familie | Ein Krankenhausbesuchs-Selfie zeigt Sophie und ihren Vater Michael nach dessen Knieoperation. | Selfie | Negativ |
| 37 | November | Familie | Ein Foto von einem Besuch bei den Eltern lässt Sophie bewusst wahrnehmen, wie sehr Petra und Michael altern. | Selfie | Neutral |
| 37 | Dezember | Alltag | Ein entspanntes Familienfoto zeigt die Feiertage zu Hause, ganz ohne Reisestress. | Fremdaufnahme | Positiv |
## Alter 38
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 38 | Januar | Persönliche Entwicklung | Ein altes Selfie von einem früheren Schreibworkshop, das Sophie wehmütig wiederfindet. | Selfie | Neutral |
| 38 | Februar | Freizeit | Ein Selfie am Laptop mit Kaffeetasse zeigt die ersten Zeilen ihres neuen Essayprojekts. | Selfie | Positiv |
| 38 | März | Beruf | Ein Foto von der Krisensitzung mit Zahlen an der Wand zeigt den erneuten finanziellen Druck des Vereins. | Fremdaufnahme | Negativ |
| 38 | April | Beruf | Ein Foto vom leeren Schreibtisch der bewusst unbesetzt gelassenen Stelle. | Fremdaufnahme | Negativ |
| 38 | Mai | Familie | Nina fotografiert Clara beim ersten Fahrradfahren, Sophie hinterherlaufend. | Fremdaufnahme | Sehr positiv |
| 38 | Juni | Partnerschaft | Ein Selfie am Küchentisch mit Wohnungsanzeigen zeigt beide beim Gespräch über eine größere Wohnung. | Selfie | Neutral |
| 38 | Juli | Wohnen | Ein Makler fotografiert Sophie und Nina vor einer besichtigten, aber nicht passenden Wohnung. | Fremdaufnahme | Neutral |
| 38 | August | Freizeit | Ein Selfie mit dem gedruckten Kulturmagazin zeigt Sophies ersten veröffentlichten Essay. | Selfie | Sehr positiv |
| 38 | September | Beruf | Ein Foto von einem Treffen mit neuen Kontakten, die durch die Veröffentlichung entstanden sind. | Fremdaufnahme | Positiv |
| 38 | Oktober | Wohnen | Ein Selfie mit dem Zusage-Schreiben für die größere Mietwohnung. | Selfie | Positiv |
| 38 | November | Wohnen | Ein Umzugshelfer fotografiert Sophie erschöpft zwischen Umzugskartons. | Fremdaufnahme | Negativ |
| 38 | Dezember | Familie | Ein Selfie zeigt Clara stolz in ihrem neuen, größeren Zimmer. | Selfie | Positiv |
## Alter 39
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 39 | Januar | Beruf | Ein Foto vom Bildschirm mit der Anfrage für regelmäßige Fachbeiträge zeigt Sophie überrascht lächelnd. | Selfie | Positiv |
| 39 | Februar | Persönliches Projekt | Ein Selfie mit ihrem ersten veröffentlichten Artikel auf dem Tablet. | Selfie | Positiv |
| 39 | März | Partnerschaft | Ein Selfie vom Abend zeigt Sophie am Laptop schreibend, während Nina im Hintergrund allein fernsieht. | Selfie | Negativ |
| 39 | April | Alltag | Ein Selfie am Schreibtisch mit dem Kalendereintrag "Schreibabend" gut sichtbar. | Selfie | Positiv |
| 39 | Mai | Familie | Ein Foto vom ersten Schultag zeigt Clara stolz mit ihrer Schultüte. | Fremdaufnahme | Sehr positiv |
| 39 | Juni | Familie | Ein Selfie mit Claras neuem Stundenplan an der Pinnwand. | Selfie | Neutral |
| 39 | Juli | Reisen | Ein Passant fotografiert die Familie am schwedischen See bei ihrem ruhigen Sommerurlaub. | Fremdaufnahme | Sehr positiv |
| 39 | August | Beruf | Ein Foto von der ersten Sitzung des städtischen Kulturbeirats zeigt Sophie als neues Mitglied. | Fremdaufnahme | Positiv |
| 39 | September | Familie | Ein Foto von Petras Abschiedsfeier zum endgültigen Ruhestand. | Fremdaufnahme | Neutral |
| 39 | Oktober | Freundschaft | Ein Gruppenfoto zeigt Sophie, Nina, Alina und ihre Partnerin bei einem gemeinsamen Abend. | Selfie | Positiv |
| 39 | November | Persönliches Projekt | Ein Selfie mit dem Überweisungsbeleg ihres ersten Honorars für einen längeren Essay. | Selfie | Positiv |
| 39 | Dezember | Persönliche Entwicklung | Ein Selfie am Schreibtisch zeigt zwei Ordner nebeneinander, beschriftet "Verein" und "Schreiben". | Selfie | Positiv |
## Alter 40
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 40 | Januar | Persönliche Entwicklung | Ein großes Geburtstagsfoto zeigt Sophie umringt von Familie, alten und neuen Freunden zu ihrem 40. Geburtstag. | Fremdaufnahme | Sehr positiv |
| 40 | Februar | Beruf | Ein erschöpftes Selfie am Schreibtisch zeigt Sophies deutliche Ermüdung nach fast vier Jahren Geschäftsführung. | Selfie | Negativ |
| 40 | März | Beruf | Ein Foto vom Übergabegespräch zeigt Sophie beim Delegieren an ihre Stellvertreterin. | Fremdaufnahme | Positiv |
| 40 | April | Persönliches Projekt | Ein Selfie mit der E-Mail-Anfrage des Verlags auf dem Bildschirm. | Selfie | Sehr positiv |
| 40 | Mai | Persönliches Projekt | Ein Selfie am Laptop mit ausgedrucktem Konzept-Entwurf für ihr erstes Buch. | Selfie | Positiv |
| 40 | Juni | Partnerschaft | Ein Selfie beim Abendessen zeigt Nina mit ernstem Blick, die vor zu vielen gleichzeitigen Projekten warnt. | Selfie | Neutral |
| 40 | Juli | Persönliche Entscheidung | Ein Foto vom Gespräch mit dem Vorstand zeigt Sophies Entscheidung, ihre Arbeitszeit zu reduzieren. | Fremdaufnahme | Positiv |
| 40 | August | Familie | Ein Foto von Clara mit ihren Großeltern Petra und Michael während ihrer ersten Woche allein bei ihnen. | Fremdaufnahme | Positiv |
| 40 | September | Partnerschaft | Ein Passant fotografiert Sophie und Nina vor dem Edinburgh Castle während ihrer kinderfreien Reisewoche. | Fremdaufnahme | Sehr positiv |
| 40 | Oktober | Persönliches Projekt | Ein Selfie mit der Zusage-E-Mail des Verlags für ihr überarbeitetes Buchkonzept. | Selfie | Sehr positiv |
| 40 | November | Finanzen | Ein Selfie am Schreibtisch mit zwei Gehaltsabrechnungen nebeneinander, eine deutlich kleiner als die andere. | Selfie | Neutral |
| 40 | Dezember | Familie | Ein Selfie von Sophie und Clara am Küchentisch, beide schreibend an ihren jeweiligen Geschichten. | Selfie | Positiv |
## Alter 41
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 41 | Januar | Beruf | Ein Foto vom neuen, kürzeren Wochenplan an der Bürotür zeigt Sophies Vier-Tage-Woche. | Fremdaufnahme | Positiv |
| 41 | Februar | Persönliches Projekt | Ein Selfie am Schreibtisch mit der ersten Kapitelüberschrift ihres Buches auf dem Bildschirm. | Selfie | Positiv |
| 41 | März | Familie | Ein Videocall-Screenshot zeigt einen sichtlich besorgten Daniel nach dem Verlust seiner Stelle. | Selfie | Negativ |
| 41 | April | Familie | Ein Selfie zeigt Sophie und Daniel gemeinsam am Laptop über seinen Bewerbungsunterlagen. | Selfie | Positiv |
| 41 | Mai | Beruf | Ein Foto von einer angespannten Teamsitzung zeigt den Widerstand gegen Sophies geplante Programmänderung. | Fremdaufnahme | Negativ |
| 41 | Juni | Beruf | Ein Foto vom Team nach der gefundenen Einigung zeigt deutlich entspanntere Gesichter. | Fremdaufnahme | Positiv |
| 41 | Juli | Familie | Nina fotografiert Clara an ihrem neuen Schlagzeug, im Hintergrund hält sich jemand die Ohren zu. | Fremdaufnahme | Neutral |
| 41 | August | Freundschaft | Ein Foto von der Eröffnungsfeier zeigt Sophie hinter der Theke von Alinas neuem Café. | Fremdaufnahme | Positiv |
| 41 | September | Persönliches Projekt | Ein Selfie mit dem ausgedruckten halben Manuskriptstapel. | Selfie | Sehr positiv |
| 41 | Oktober | Partnerschaft | Ein Selfie vom gemeinsamen Abend wirkt ruhig, entstand aber nach einer erneuten Diskussion über zu wenig gemeinsame Zeit. | Selfie | Negativ |
| 41 | November | Partnerschaft | Ein Selfie vor einem neu aufgehängten "Date Night"-Kalender zeigt beide entschlossen. | Selfie | Positiv |
| 41 | Dezember | Familie | Ein Videocall-Screenshot zeigt einen erleichterten Daniel nach seiner neuen Stelle. | Selfie | Positiv |
## Alter 42
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 42 | Januar | Persönliches Projekt | Ein Selfie mit dem ausgedruckten kompletten ersten Manuskript-Entwurf. | Selfie | Sehr positiv |
| 42 | Februar | Beruf | Eine Kollegin fotografiert Sophie in einem nachdenklichen Moment am Bürofenster. | Fremdaufnahme | Neutral |
| 42 | März | Persönliches Projekt | Ein frustriertes Selfie zeigt Sophie über dem mit Anmerkungen übersäten Lektorat ihres Manuskripts. | Selfie | Negativ |
| 42 | April | Persönliche Entwicklung | Ein Selfie am Schreibtisch zeigt Sophie zufrieden über der überarbeiteten, verbesserten Fassung. | Selfie | Positiv |
| 42 | Mai | Familie | Ein Selfie am Küchentisch zeigt Sophie und Clara im ernsten Gespräch über deren Schulfreundschaft-Probleme. | Selfie | Negativ |
| 42 | Juni | Familie | Ein Selfie zeigt beide entspannter, nachdem Sophie bewusst nur zugehört hat. | Selfie | Positiv |
| 42 | Juli | Reisen | Ein Passant fotografiert Sophie, Nina und Clara vor dem Kolosseum während ihrer dreiwöchigen Italienreise. | Fremdaufnahme | Sehr positiv |
| 42 | August | Beruf | Ein Foto vom Gespräch zeigt Sophies Stellvertreterin, die Interesse an der Geschäftsführung signalisiert. | Fremdaufnahme | Positiv |
| 42 | September | Beruf | Ein Foto von der Vorstandssitzung zeigt die Entwicklung des Übergabeplans. | Fremdaufnahme | Positiv |
| 42 | Oktober | Persönliches Projekt | Ein Selfie mit der Zusage-E-Mail des Verlags für die endgültige Buchfassung. | Selfie | Sehr positiv |
| 42 | November | Finanzen | Ein Selfie mit dem ersten Honorarscheck für ihr Buch. | Selfie | Positiv |
| 42 | Dezember | Partnerschaft | Ein Hotelmitarbeiter fotografiert Sophie vor dem Schreib- und Literaturhotel, das Nina ihr zum Hochzeitstag geschenkt hat. | Fremdaufnahme | Sehr positiv |
## Alter 43
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 43 | Januar | Beruf | Ein Foto von der Team-Ankündigung zeigt Sophie, wie sie ihren geplanten Rücktritt als Geschäftsführerin verkündet. | Fremdaufnahme | Neutral |
| 43 | Februar | Beruf | Ein Foto von einer angespannten Teamsitzung zeigt enttäuschte Kollegen. | Fremdaufnahme | Negativ |
| 43 | März | Persönliche Entwicklung | Ein altes Team-Gruppenfoto, das Sophie nachdenklich betrachtet, lässt sie merken, wie stark ihre Identität mit dem Verein verbunden ist. | Selfie | Neutral |
| 43 | April | Persönliches Projekt | Ein Selfie mit dem druckfrischen ersten Buch in der Hand. | Selfie | Sehr positiv |
| 43 | Mai | Persönliches Projekt | Ein Foto von der ausverkauften Lesung zeigt Sophie am Rednerpult, sichtlich überwältigt. | Fremdaufnahme | Sehr positiv |
| 43 | Juni | Beruf | Ein Foto zeigt Sophie und ihre Nachfolgerin gemeinsam am Schreibtisch beim Einarbeiten. | Fremdaufnahme | Positiv |
| 43 | Juli | Familie | Ein Foto von Clara am Büchertisch zeigt sie, wie sie Sophie erstmals bei einer Lesung hilft. | Fremdaufnahme | Positiv |
| 43 | August | Reisen | Ein Selfie am Ostseestrand zeigt Sophie nachdenklich während ihrer Solo-Tage. | Selfie | Positiv |
| 43 | September | Freundschaft | Ein Selfie vor Alinas Café zeigt beide beim Planen gemeinsamer Lesungen und Kulturabende. | Selfie | Positiv |
| 43 | Oktober | Beruf | Ein Foto vom Übergabefest zeigt Sophie beim Feiern ihres Wechsels in eine projektbezogene Rolle. | Fremdaufnahme | Positiv |
| 43 | November | Familie | Ein Selfie beim Besuch zeigt Sophie und ihren Vater Michael im Garten. | Selfie | Neutral |
| 43 | Dezember | Beruf | Ein Abschiedsfoto mit dem Team zeigt Sophie mit einem großen Blumenstrauß nach ihrer letzten Woche als Geschäftsführerin. | Fremdaufnahme | Sehr positiv |
## Alter 44
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 44 | Januar | Beruf | Ein Selfie am neuen, kleineren Schreibtisch ohne Führungsverantwortung. | Selfie | Positiv |
| 44 | Februar | Persönliches Projekt | Ein Selfie mit einem Terminkalender voller neuer Workshop- und Vortragsanfragen. | Selfie | Positiv |
| 44 | März | Finanzen | Ein Selfie mit einer Haushaltsbuch-App auf dem Handy zeigt Sophies genauere Finanzplanung. | Selfie | Neutral |
| 44 | April | Partnerschaft | Ein Foto von Nina in ihrem neuen, verantwortungsvolleren Büro. | Fremdaufnahme | Positiv |
| 44 | Mai | Familie | Ein Selfie im Hintergrund zeigt Clara mit Kopfhörern, kaum Blickkontakt suchend. | Selfie | Neutral |
| 44 | Juni | Persönliche Entwicklung | Ein altes Foto der kleinen Clara neben einem aktuellen Foto der Teenagerin zeigt den Wandel deutlich. | Selfie | Neutral |
| 44 | Juli | Reisen | Ein Passant fotografiert Sophie und Nina bei ihrer ersten reinen Paarreise seit Jahren. | Fremdaufnahme | Sehr positiv |
| 44 | August | Beruf | Ein Foto vom neuen Projektort zeigt Sophie bei ihrer sechsmonatigen Beratungstätigkeit in einer anderen Stadt. | Fremdaufnahme | Positiv |
| 44 | September | Freundschaft | Ein aktuelles Gruppenfoto im Vergleich zu einem älteren zeigt den lockereren Kontakt zu früheren Kollegen. | Selfie | Neutral |
| 44 | Oktober | Persönliches Projekt | Ein Selfie mit einem Notizbuch voller erster Ideen für ihr zweites Buch. | Selfie | Positiv |
| 44 | November | Familie | Ein Foto vom Familientreffen bei den Eltern zeigt das Gespräch über Unterstützung im Alter. | Fremdaufnahme | Neutral |
| 44 | Dezember | Familie | Ein Foto vom Familientisch mit Unterlagen zu Vollmachten und Wohnfragen. | Fremdaufnahme | Positiv |
## Alter 45
| Alter | Monat | Lebensbereich | Bildmotiv | Foto | Bewertung |
| --- | --- | --- | --- | --- | --- |
| 45 | Januar | Persönliche Entwicklung | Ein entspanntes Selfie zu ihrem 45. Geburtstag zeigt Sophie zufrieden mit ihrer freieren Lebensphase. | Selfie | Positiv |
| 45 | Februar | Beruf | Ein Foto von der Auftragsunterzeichnung zeigt Sophie bei ihrer neuen Kulturstrategie-Beratung. | Fremdaufnahme | Sehr positiv |
| 45 | März | Familie | Ein Selfie zeigt Clara mit Prospekten zu einem möglichen Auslands-Schuljahr. | Selfie | Neutral |
| 45 | April | Partnerschaft | Ein Selfie von einem gemütlichen Abend zu zweit zeigt Sophie und Nina beim Planen ihrer nächsten gemeinsamen Jahre. | Selfie | Positiv |
| 45 | Mai | Persönliches Projekt | Ein Selfie mit der E-Mail eines Verlags, der Interesse an ihrem zweiten Buch zeigt. | Selfie | Sehr positiv |
| 45 | Juni | Freundschaft | Ein Foto zeigt Sophie und Alina, wie sie ein altes Jugendfoto neben ein aktuelles halten, um zwanzig Jahre Freundschaft zu feiern. | Fremdaufnahme | Sehr positiv |
| 45 | Juli | Familie | Ein Foto ihrer Eltern von unterwegs, das sie Sophie von ihrer ersten langen Ruhestandsreise schicken. | Fremdaufnahme | Positiv |
| 45 | August | Freizeit | Ein Selfie am Schreibtisch mit Blick nach draußen zeigt Sophie entspannt während ihrer vier freien Wochen. | Selfie | Positiv |
| 45 | September | Beruf | Ein Foto vom Gespräch zeigt Sophie, wie sie ein Angebot für eine erneute Vollzeit-Führungsposition ablehnt. | Fremdaufnahme | Positiv |
| 45 | Oktober | Persönliches Projekt | Ein Selfie mit dem unterschriebenen Vertrag für ihr zweites Buch. | Selfie | Sehr positiv |
| 45 | November | Partnerschaft | Ein Passant fotografiert Sophie und Nina bei ihrer Jubiläumsreise zum Hochzeitstag. | Fremdaufnahme | Sehr positiv |
| 45 | Dezember | Persönliche Entwicklung | Ein Familienfoto zum Jahresende zeigt Sophie, Nina und Clara entspannt auf dem Sofa – ein stiller Rückblick auf zwei große Beziehungen, eine Familie und eine lange Karriere. | Fremdaufnahme | Sehr positiv |

56
dev/backgrounds/README.md Normal file
View file

@ -0,0 +1,56 @@
# Hintergründe für die LifeWave – Sichtungsordner
Zehn Hintergründe, von grafisch bis fotografisch, alle quadratisch 2048 px,
damit `background-size: cover` hoch und quer gleichmäßig beschneidet. Die
Bildmitte ist überall ruhig, weil dort die Welle mit ihren Punkten läuft.
Palette: Tinte #292a2b / #1a1b1c, Gold #f5c26b / #d59040, Papier #f7f3ea.
| Nr. | Name | Quelle | Linienmodus | Passendes Preset |
| --- | --- | --- | --- | --- |
| 01 | Tinte | Gemini, ink-gewaschenes Papier | Glow | Gold |
| 02 | Goldstaub | Gemini, Goldstaub auf Samt | Glow | Gold, Seide |
| 03 | Horizont | Gemini, Nachglühen über leerer Ebene | Glow, Trennung | Abendrot |
| 04 | Papier | Gemini, Baumwollpapier | Einfarbig | Papier |
| 05 | Leinen | Gemini, flaches Anthrazit-Leinen | Glow | Seide |
| 06a | Aquarell warm | Gemini, Sepia/Ocker in den Ecken | Einfarbig | Papier |
| 06b | Aquarell dunkel | Gemini, Indigo-Tusche oben und unten | Einfarbig dunkel | Aurora |
| 07 | Kohle | Gemini, Schiefertafel | Glow, Einfarbig hell | Mondsichel |
| 08 | Nebel | Gemini | Glow | Aurora, Milchstraße |
| 09 | Dämmerung | Gemini | Glow | Abendrot |
| 10 | Nachthimmel | Gemini | Glow, Mondsichel | Mondsichel, Aurora |
Alle elf liegen in `photo/`, die unveränderten 2K-Ausgaben in `photo/original/`.
`uebersicht.jpg` zeigt den aktuellen Stand. Frühere Fassungen zum Vergleich:
`v1/` (erster Durchgang, grafische als SVG gerendert), `v2/` (zweiter
Durchgang von Tinte, Papier, Leinen und Aquarell warm, mit Blattkanten und
Falten). Die SVG-Quellen in `v1/graphic/` bleiben als Rückfall, falls eine
Version ohne KI gewünscht ist.
## Neu erzeugen
```bash
cd dev/tools/backgrounds
python3 generate_photo.py # alle elf, je eine Gemini-Anfrage
python3 generate_photo.py 08-nebel # nur eines
python3 build_graphic.py # SVG-Fassung der grafischen (v1), ohne API
```
Die Fotos brauchen `GOOGLE_API_KEY` in `backend/.env`, siehe
`documentation/bildgenerierung-gemini-api.md`. Jede Anfrage zählt gegen das
Tageslimit von 100.
## Übernahme in die App
Seit 2026-09-25 eingebaut: alle elf aus `photo/` plus die vier Fassungen aus
`v2/` (Tinte Blatt, Papier Bütten, Leinen Falten, Aquarell Streifen), also
15 zur Auswahl. Dateien liegen unter `frontend/public/images/backgrounds/`
(2048 px, JPEG q5) mit Vorschauen in `thumbs/`; Namen, Reihenfolge und
Helligkeit (`tone`, steuert die Logo-Variante) in
`frontend/src/config/backgrounds.js`. Die alten `bg-image-1…10` sind
entfernt; gespeicherte Einstellungen mit einem alten Pfad werden auf den
nächstliegenden Nachfolger umgeschrieben.
Aussortieren heißt: Eintrag aus der Config nehmen und Datei löschen. Presets
in `wavePresets.js` zeigen auf: Nachthimmel, Leinen, Nebel, Tinte, Kohle,
Horizont, Dämmerung.

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View file

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 30.8.1, SVG Export Plug-In . SVG Version: 9.03 Build 0) -->
<svg version="1.0" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 595.3 149" style="enable-background:new 0 0 595.3 149;" xml:space="preserve">
<style type="text/css">
.st0{fill:#F7F3E9;}
.st1{fill:none;stroke:#D59040;stroke-width:4;stroke-miterlimit:10;}
.st2{opacity:0.7;fill:url(#SVGID_1_);}
.st3{opacity:0.7;fill:url(#SVGID_00000010268479788305058960000017491844122260017031_);}
.st4{fill:#F5C26B;}
.st5{opacity:0.4;fill:#F5C26B;}
.st6{fill:none;stroke:#D59040;stroke-miterlimit:10;}
</style>
<path class="st0" d="M90.7,109.9c-0.2-0.3-0.5-0.4-1-0.4c-0.5,0-0.9,0.2-1.3,0.6c-0.4,0.4-0.8,0.9-1.2,1.4c-0.4,0.6-1.1,1-1.9,1.4
c-0.8,0.4-2,0.6-3.5,0.6c-2.1,0-3.7-0.7-4.9-2.1c-1.2-1.4-1.8-3.5-1.8-6.2V65.4h11.3c1.3,0,2.3-0.2,3-0.6c0.7-0.4,1-0.9,1-1.6
c0-0.5-0.2-0.8-0.5-1.1c-0.3-0.2-0.9-0.4-1.6-0.4H75.2v-11c0-0.6-0.2-1.1-0.5-1.4C74.4,49.1,74,49,73.4,49c-0.5,0-1,0.2-1.4,0.5
c-0.4,0.4-0.8,0.9-1.2,1.7l-3.1,8.3c-0.3,0.9-0.8,1.5-1.5,1.8c-0.7,0.3-1.6,0.5-2.8,0.5h-4c-0.6,0-1.1,0.1-1.4,0.4
c-0.3,0.2-0.5,0.6-0.5,1.1c0,0.4,0.2,0.7,0.5,1c0.4,0.3,1.1,0.6,2.1,0.9l3.6,0.8c1,0.3,1.8,0.7,2.2,1.2c0.4,0.5,0.6,1.2,0.6,2.2
v37.1c0,4.1,1.1,7.2,3.4,9.2c2.3,2,5.3,3,9,3c2.3,0,4.3-0.4,6.1-1.2c1.8-0.8,3.2-1.8,4.2-2.9c1-1.2,1.5-2.3,1.5-3.5
C91,110.5,90.9,110.1,90.7,109.9z"/>
<path class="st0" d="M114.8,32.5v78.4c0,0.9,0.2,1.6,0.7,2.1c0.5,0.5,1.1,0.9,2,1l3.1,0.6c1.3,0.3,1.9,0.9,1.9,1.6
c0,1-0.7,1.6-2.2,1.6h-19.7c-0.7,0-1.2-0.1-1.6-0.4c-0.4-0.3-0.5-0.6-0.5-1.1c0-0.4,0.2-0.7,0.5-1c0.3-0.3,0.8-0.5,1.4-0.6l3.3-0.7
c0.9-0.2,1.5-0.5,2-1c0.5-0.5,0.7-1.2,0.7-2.2V39.4c0-0.8-0.1-1.4-0.5-1.7c-0.3-0.4-0.8-0.6-1.5-0.6l-5-0.1c-0.6,0-1.1-0.2-1.4-0.4
c-0.3-0.2-0.4-0.5-0.4-0.9c0-0.4,0.1-0.7,0.4-1c0.3-0.3,0.8-0.5,1.6-0.8l8.7-2.9c1-0.4,1.8-0.6,2.5-0.8c0.7-0.2,1.2-0.2,1.7-0.2
c0.8,0,1.4,0.2,1.8,0.6C114.7,31,114.8,31.7,114.8,32.5z"/>
<path class="st0" d="M258.1,109.9c-0.2-0.3-0.5-0.4-1-0.4c-0.5,0-0.9,0.2-1.3,0.6c-0.4,0.4-0.8,0.9-1.2,1.4c-0.4,0.6-1.1,1-1.9,1.4
c-0.8,0.4-2,0.6-3.5,0.6c-2.1,0-3.7-0.7-4.9-2.1c-1.2-1.4-1.8-3.5-1.8-6.2V65.4h11.3c1.3,0,2.3-0.2,3-0.6c0.7-0.4,1-0.9,1-1.6
c0-0.5-0.2-0.8-0.5-1.1c-0.3-0.2-0.9-0.4-1.6-0.4h-13.2v-11c0-0.6-0.2-1.1-0.5-1.4c-0.3-0.3-0.7-0.5-1.3-0.5c-0.5,0-1,0.2-1.4,0.5
c-0.4,0.4-0.8,0.9-1.2,1.7l-3.1,8.3c-0.3,0.9-0.8,1.5-1.5,1.8c-0.7,0.3-1.6,0.5-2.8,0.5h-4c-0.6,0-1.1,0.1-1.4,0.4
c-0.3,0.2-0.5,0.6-0.5,1.1c0,0.4,0.2,0.7,0.5,1c0.4,0.3,1.1,0.6,2.1,0.9l3.6,0.8c1,0.3,1.8,0.7,2.2,1.2c0.4,0.5,0.6,1.2,0.6,2.2
v37.1c0,4.1,1.1,7.2,3.4,9.2c2.3,2,5.3,3,9,3c2.3,0,4.3-0.4,6.1-1.2c1.8-0.8,3.2-1.8,4.2-2.9c1-1.2,1.5-2.3,1.5-3.5
C258.4,110.5,258.3,110.1,258.1,109.9z"/>
<path class="st0" d="M407.3,76l-2-1.9l2.9-2.5c4.6-4,8.5-6.8,11.9-8.4c3.4-1.6,6.7-2.4,9.9-2.4c5,0,9,1.5,12,4.6
c3,3.1,4.5,7.3,4.5,12.7v32.5c0,1,0.3,1.8,0.8,2.4c0.5,0.6,1.3,1,2.2,1.2l2.9,0.6c0.6,0.2,1.1,0.4,1.4,0.6c0.3,0.3,0.5,0.6,0.5,1
c0,0.4-0.2,0.8-0.5,1.1c-0.3,0.3-0.9,0.4-1.6,0.4h-19.9c-1.5,0-2.2-0.5-2.2-1.6c0-0.8,0.6-1.3,1.9-1.6l3.3-0.6
c1-0.2,1.7-0.6,2.1-1.1c0.4-0.6,0.7-1.3,0.7-2.3V79.4c0-4.5-1.1-7.8-3.3-10c-2.2-2.2-5.2-3.3-8.9-3.3c-2.2,0-4.7,0.6-7.3,1.8
c-2.6,1.2-5.5,3.1-8.6,5.8L407.3,76z"/>
<path class="st0" d="M511.4,100.7c-0.2-0.4-0.6-0.5-1.1-0.5c-0.4,0-0.7,0.1-0.9,0.3c-0.3,0.2-0.5,0.6-0.8,1.2c-1,3.3-3,6-6.2,8.2
s-6.9,3.2-11.3,3.2c-6.4,0-11.5-2.2-15.5-6.5c-4-4.3-6-10.5-6-18.6h36.3c1.9,0,3.3-0.4,4.3-1.2c1-0.8,1.4-2,1.4-3.5
c0-4.4-1-8.3-2.9-11.7c-1.9-3.3-4.6-6-8-7.9c-3.4-1.9-7.4-2.9-11.9-2.9c-5.6,0-10.4,1.3-14.5,4c-4.1,2.7-7.3,6.2-9.5,10.7
c-2.2,4.5-3.4,9.6-3.4,15.2c0,5.8,1.1,10.8,3.4,15c2.3,4.3,5.4,7.6,9.6,9.9s8.9,3.5,14.4,3.5c4.2,0,7.9-0.8,11.2-2.3s6-3.6,8.1-6.2
c2.1-2.6,3.2-5.5,3.6-8.6C511.7,101.5,511.6,101,511.4,100.7z M471.8,75.5c1.5-3.5,3.6-6.2,6.4-8.2c2.7-2,5.9-3,9.5-3
c4.5,0,8.1,1.6,10.8,4.9c2.7,3.3,4.1,7.6,4.1,13c0,1.7-1,2.5-2.9,2.5h-30C469.9,81.3,470.6,78.2,471.8,75.5z"/>
<path class="st1" d="M17.4,97.3"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="277.5667" y1="76.1994" x2="294.7258" y2="150.1156">
<stop offset="0" style="stop-color:#F5C26B"/>
<stop offset="1" style="stop-color:#D59040"/>
</linearGradient>
<path class="st2" d="M532,80.7c-38.5,17.9-77.7,27-119.2,27.8c-44.6,0.9-87.5-7.8-129-16.1c-39.1-7.8-79.6-15.9-120.9-15.9
c-0.3,0-0.6,0-0.8,0c-46.7,0.1-89.8,10.9-131.8,32.9l1.9,3.5C73.5,91.2,116,80.6,162,80.5c0.3,0,0.6,0,0.8,0
c40.9,0,81.2,8.1,120.1,15.9c40,8,80.6,16.2,123.5,16.2c40.3,0,82.5-7.2,128-28.5C533.5,83,532.7,81.9,532,80.7z"/>
<linearGradient id="SVGID_00000079453371929638128150000013207070431988704682_" gradientUnits="userSpaceOnUse" x1="278.2577" y1="78.0403" x2="295.4174" y2="151.9591">
<stop offset="0" style="stop-color:#F5C26B"/>
<stop offset="1" style="stop-color:#D59040"/>
</linearGradient>
<path style="opacity:0.7;fill:url(#SVGID_00000079453371929638128150000013207070431988704682_);" d="M533,82.2
c-38.8,18.2-78.3,27.4-120.2,28.2c-44.6,0.9-87.5-7.8-129-16.1c-39.1-7.8-79.6-15.9-120.9-15.9c-0.3,0-0.6,0-0.8,0
c-46.7,0.1-89.8,10.9-131.8,32.9l1.9,3.5C73.5,93.2,116,82.6,162,82.5c0.3,0,0.6,0,0.8,0c40.9,0,81.2,8.1,120.1,15.9
c40,8,80.6,16.2,123.5,16.2c40.7,0,83.3-7.4,129.3-29.1C534.7,84.4,533.8,83.4,533,82.2z"/>
<path class="st0" d="M113.2,76l-1.9-1.9l2.9-2.5c4.6-4,8.5-6.8,11.9-8.4c3.4-1.6,6.7-2.4,10-2.4c5,0,9,1.5,12.1,4.6
c3,3.1,4.6,7.3,4.6,12.7v32.5c0,1,0.3,1.8,0.8,2.4c0.5,0.6,1.2,1,2.2,1.2l2.9,0.6c0.6,0.2,1.1,0.4,1.4,0.6c0.3,0.3,0.5,0.6,0.5,1
c0,0.4-0.2,0.8-0.5,1.1c-0.3,0.3-0.9,0.4-1.6,0.4h-19.8c-1.5,0-2.2-0.5-2.2-1.6c0-0.8,0.6-1.3,1.9-1.6l3.3-0.6
c1-0.2,1.7-0.6,2.1-1.1c0.4-0.6,0.7-1.3,0.7-2.3V79.4c0-4.5-1.1-7.8-3.4-10c-2.2-2.2-5.2-3.3-8.9-3.3c-2.2,0-4.7,0.6-7.3,1.8
c-2.6,1.2-5.5,3.1-8.7,5.8L113.2,76z"/>
<path class="st0" d="M220.9,111.7c-0.2-0.2-0.4-0.4-0.7-0.4c-0.3,0-0.6,0.1-0.8,0.3c-0.2,0.2-0.4,0.4-0.6,0.6
c-0.3,0.5-0.6,1-1.1,1.4c-0.4,0.5-1.2,0.7-2.3,0.7c-0.9,0-1.6-0.3-2.1-0.9c-0.5-0.6-0.8-1.7-0.8-3.1V76.6c0-4.8-1.5-8.6-4.6-11.5
c-3.1-2.9-7.7-4.3-14-4.3c-4.6,0-8.5,0.7-11.8,2.1c-3.3,1.4-5.8,3.2-7.6,5.5c-1.8,2.2-2.7,4.5-2.7,6.9c0,1.4,0.4,2.5,1.2,3.3
c0.8,0.8,1.9,1.3,3.3,1.3c1.6,0,2.8-0.4,3.8-1.1c0.9-0.8,1.4-1.9,1.4-3.4v-4.1c0-2.1,0.9-3.9,2.6-5.4c1.8-1.5,4.5-2.2,8.2-2.2
c3.8,0,6.7,1.1,8.8,3.2c2,2.1,3.1,5.3,3.1,9.4v15.5c-1.4-0.5-2.9-0.9-4.6-1.3c-2.4-0.6-5.1-0.8-7.9-0.8c-7.1,0-12.6,1.5-16.6,4.5
c-4,3-6,6.7-6,11.2c0,4.3,1.6,7.6,4.7,10.1c3.1,2.4,7.1,3.7,12,3.7c4.2,0,8.2-0.9,11.9-2.8c2.6-1.3,4.9-2.9,6.9-4.9
c0.1,2.5,0.8,4.4,2.2,5.6c1.5,1.4,3.6,2.1,6.3,2.1c1.8,0,3.2-0.3,4.4-1c1.2-0.7,2.1-1.5,2.7-2.5c0.6-1,0.9-2,0.9-2.9
C221.2,112.3,221.1,112,220.9,111.7z M188.9,114.8c-3.2,0-5.8-0.9-7.9-2.8c-2.1-1.8-3.1-4.5-3.1-7.9c0-3.6,1.3-6.4,3.9-8.5
c2.6-2.1,6.2-3.1,10.9-3.1c2.4,0,4.7,0.3,6.8,0.8c1.6,0.4,3.1,0.9,4.6,1.4v13.5c-1.8,1.8-3.8,3.2-6,4.3
C195.2,114.1,192.1,114.8,188.9,114.8z"/>
<path class="st0" d="M297.5,64.3c-3.2,0-5.8,0.9-7.7,2.6c-1.9,1.7-2.9,4-2.9,6.9c0,1.8,0.4,3.3,1.3,4.7c0.9,1.4,2.3,2.7,4.5,3.8
c2.1,1.1,5.1,2.2,8.9,3.3c4.9,1.2,8.8,2.8,11.7,4.6s5,3.8,6.2,6c1.2,2.2,1.8,4.6,1.8,7.3c0,4.6-1.7,8.4-5,11.3
c-3.4,2.9-8.1,4.4-14.2,4.4c-2.3,0-4.3-0.2-5.8-0.7c-1.5-0.4-2.8-0.9-3.9-1.3c-1.1-0.4-2-0.6-2.8-0.6c-0.8,0-1.3,0.2-1.7,0.7
c-0.4,0.4-0.7,0.9-1,1.3c-0.3,0.4-0.8,0.6-1.4,0.6c-0.4,0-0.7-0.1-1-0.4c-0.2-0.3-0.5-0.7-0.8-1.3l-4.8-13.2c-0.3-0.8-0.4-1.4-0.3-2
c0.1-0.6,0.5-1,1-1.2c0.6-0.2,1.1-0.2,1.5,0c0.4,0.3,0.8,0.7,1.1,1.2c1.8,3.5,3.7,6.2,5.7,8.2c2.1,2,4.3,3.3,6.6,4.1
c2.3,0.8,4.7,1.1,7.2,1.1c4.2,0,7.3-0.9,9.4-2.7c2.1-1.8,3.1-4.2,3.1-7.1c0-1.7-0.5-3.3-1.4-4.8c-0.9-1.5-2.5-2.9-4.7-4.1
c-2.3-1.3-5.4-2.5-9.5-3.5c-4.6-1.2-8.3-2.6-11-4.2c-2.7-1.6-4.7-3.5-5.9-5.7c-1.2-2.2-1.8-4.7-1.8-7.6s0.7-5.4,2.1-7.7
c1.4-2.3,3.4-4,6.1-5.3c2.6-1.3,5.8-2,9.4-2c2.3,0,4.2,0.2,5.7,0.7c1.5,0.5,2.7,1,3.7,1.4c1,0.5,1.8,0.7,2.5,0.7
c0.8,0,1.4-0.2,1.8-0.7c0.4-0.5,0.7-1,1-1.4c0.3-0.5,0.8-0.7,1.3-0.7c0.3,0,0.6,0.1,0.9,0.3c0.3,0.2,0.5,0.6,0.6,1.2l4,12.5
c0.3,0.9,0.4,1.7,0.4,2.4s-0.4,1.2-1,1.5c-0.6,0.2-1,0.1-1.5-0.2s-0.9-1-1.3-1.8c-2.1-4.4-4.8-7.6-8-9.5
C304.4,65.2,301,64.3,297.5,64.3z"/>
<path class="st0" d="M414.7,114.7l-2.9-0.6c-0.9-0.2-1.6-0.6-2.2-1.2c-0.5-0.6-0.8-1.3-0.8-2.3V78.1c0-5.4-1.5-9.6-4.5-12.7
c-3-3.1-7-4.6-12-4.6c-3.2,0-6.5,0.8-9.9,2.4c-3.2,1.5-7,4.2-11.2,7.8v-7.2c0-0.8-0.2-1.4-0.6-1.8s-1-0.6-1.8-0.6
c-0.4,0-1,0.1-1.7,0.2c-0.7,0.1-1.5,0.4-2.5,0.8l-8.7,2.9c-0.8,0.2-1.3,0.5-1.6,0.8c-0.3,0.3-0.4,0.6-0.4,1c0,0.4,0.1,0.7,0.4,0.9
c0.3,0.2,0.7,0.4,1.4,0.4l5,0.1c0.7,0,1.2,0.2,1.5,0.6c0.3,0.4,0.5,0.9,0.5,1.7v40.1c0,1-0.2,1.7-0.7,2.2c-0.5,0.5-1.1,0.8-2,1
l-3.3,0.7c-0.6,0.2-1.1,0.4-1.4,0.6c-0.3,0.3-0.5,0.6-0.5,1c0,0.4,0.2,0.8,0.5,1.1c0.4,0.3,0.9,0.4,1.6,0.4h19.7
c1.5,0,2.2-0.5,2.2-1.6c0-0.8-0.6-1.3-1.9-1.6l-3.1-0.6c-0.8-0.2-1.5-0.5-1.9-1c-0.5-0.5-0.7-1.2-0.7-2.1V74.6l1.2-1
c3.2-2.6,6-4.6,8.6-5.8c2.6-1.2,5-1.8,7.3-1.8c3.7,0,6.6,1.1,8.9,3.3s3.3,5.6,3.3,10v31.1c0,1-0.2,1.8-0.7,2.3
c-0.4,0.6-1.1,0.9-2.1,1.1l-3.3,0.6c-1.2,0.3-1.9,0.9-1.9,1.6c0,1,0.7,1.6,2.2,1.6h19.7c1.4,0,2.1-0.5,2.1-1.6
C416.5,115.6,415.9,115,414.7,114.7z"/>
<path class="st0" d="M269.5,42.7c2.2,0,3.8,0.6,5,1.7c1.1,1.1,1.7,2.6,1.7,4.3c0,1.9-0.7,3.4-2,4.6c-1.4,1.2-3,1.8-5,1.8
c-2.4,0-4.3-0.9-5.8-2.6c-1.5-1.7-2.3-4.6-2.3-8.5c0-3.8,0.8-7,2.3-9.6c1.5-2.6,3.3-4.6,5.4-5.9c2.1-1.4,3.8-2.2,5.4-2.4
c0.7-0.1,1.2-0.1,1.6,0.1c0.4,0.2,0.6,0.5,0.7,1c0,0.4-0.1,0.8-0.4,1.1c-0.3,0.4-1,0.8-2,1.2c-1.7,0.7-3.1,1.6-4.4,2.7
c-1.3,1.1-2.2,2.4-2.9,3.8c-0.7,1.4-1,2.7-1,4c0,0.9,0.2,1.6,0.7,2c0.4,0.5,1.2,0.7,2.4,0.7H269.5z"/>
<circle class="st4" cx="33.2" cy="111" r="5.2"/>
<g>
<circle class="st5" cx="553" cy="67.7" r="24.7"/>
<circle class="st5" cx="553" cy="67.7" r="17.7"/>
<circle class="st4" cx="553" cy="67.7" r="11.1"/>
</g>
<path class="st6" d="M-43.7-26.5"/>
<path class="st1" d="M46.8-46"/>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

View file

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 30.8.1, SVG Export Plug-In . SVG Version: 9.03 Build 0) -->
<svg version="1.0" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 595.3 149" style="enable-background:new 0 0 595.3 149;" xml:space="preserve">
<style type="text/css">
.st0{fill:#292A2B;}
.st1{fill:none;stroke:#D59040;stroke-width:4;stroke-miterlimit:10;}
.st2{opacity:0.7;fill:url(#SVGID_1_);}
.st3{opacity:0.7;fill:url(#SVGID_00000033328845987633311050000001729256314044772286_);}
.st4{fill:#F5C26B;}
.st5{opacity:0.4;fill:#D59040;}
.st6{fill:#D59040;}
</style>
<path class="st0" d="M90.7,109.9c-0.2-0.3-0.5-0.4-1-0.4c-0.5,0-0.9,0.2-1.3,0.6c-0.4,0.4-0.8,0.9-1.2,1.4c-0.4,0.6-1.1,1-1.9,1.4
c-0.8,0.4-2,0.6-3.5,0.6c-2.1,0-3.7-0.7-4.9-2.1c-1.2-1.4-1.8-3.5-1.8-6.2V65.4h11.3c1.3,0,2.3-0.2,3-0.6c0.7-0.4,1-0.9,1-1.6
c0-0.5-0.2-0.8-0.5-1.1c-0.3-0.2-0.9-0.4-1.6-0.4H75.2v-11c0-0.6-0.2-1.1-0.5-1.4C74.4,49.1,74,49,73.4,49c-0.5,0-1,0.2-1.4,0.5
c-0.4,0.4-0.8,0.9-1.2,1.7l-3.1,8.3c-0.3,0.9-0.8,1.5-1.5,1.8c-0.7,0.3-1.6,0.5-2.8,0.5h-4c-0.6,0-1.1,0.1-1.4,0.4
c-0.3,0.2-0.5,0.6-0.5,1.1c0,0.4,0.2,0.7,0.5,1c0.4,0.3,1.1,0.6,2.1,0.9l3.6,0.8c1,0.3,1.8,0.7,2.2,1.2c0.4,0.5,0.6,1.2,0.6,2.2
v37.1c0,4.1,1.1,7.2,3.4,9.2c2.3,2,5.3,3,9,3c2.3,0,4.3-0.4,6.1-1.2c1.8-0.8,3.2-1.8,4.2-2.9c1-1.2,1.5-2.3,1.5-3.5
C91,110.5,90.9,110.1,90.7,109.9z"/>
<path class="st0" d="M114.8,32.5v78.4c0,0.9,0.2,1.6,0.7,2.1c0.5,0.5,1.1,0.9,2,1l3.1,0.6c1.3,0.3,1.9,0.9,1.9,1.6
c0,1-0.7,1.6-2.2,1.6h-19.7c-0.7,0-1.2-0.1-1.6-0.4c-0.4-0.3-0.5-0.6-0.5-1.1c0-0.4,0.2-0.7,0.5-1c0.3-0.3,0.8-0.5,1.4-0.6l3.3-0.7
c0.9-0.2,1.5-0.5,2-1c0.5-0.5,0.7-1.2,0.7-2.2V39.4c0-0.8-0.1-1.4-0.5-1.7c-0.3-0.4-0.8-0.6-1.5-0.6l-5-0.1c-0.6,0-1.1-0.2-1.4-0.4
c-0.3-0.2-0.4-0.5-0.4-0.9c0-0.4,0.1-0.7,0.4-1c0.3-0.3,0.8-0.5,1.6-0.8l8.7-2.9c1-0.4,1.8-0.6,2.5-0.8c0.7-0.2,1.2-0.2,1.7-0.2
c0.8,0,1.4,0.2,1.8,0.6C114.7,31,114.8,31.7,114.8,32.5z"/>
<path class="st0" d="M258.1,109.9c-0.2-0.3-0.5-0.4-1-0.4c-0.5,0-0.9,0.2-1.3,0.6c-0.4,0.4-0.8,0.9-1.2,1.4c-0.4,0.6-1.1,1-1.9,1.4
c-0.8,0.4-2,0.6-3.5,0.6c-2.1,0-3.7-0.7-4.9-2.1c-1.2-1.4-1.8-3.5-1.8-6.2V65.4h11.3c1.3,0,2.3-0.2,3-0.6c0.7-0.4,1-0.9,1-1.6
c0-0.5-0.2-0.8-0.5-1.1c-0.3-0.2-0.9-0.4-1.6-0.4h-13.2v-11c0-0.6-0.2-1.1-0.5-1.4c-0.3-0.3-0.7-0.5-1.3-0.5c-0.5,0-1,0.2-1.4,0.5
c-0.4,0.4-0.8,0.9-1.2,1.7l-3.1,8.3c-0.3,0.9-0.8,1.5-1.5,1.8c-0.7,0.3-1.6,0.5-2.8,0.5h-4c-0.6,0-1.1,0.1-1.4,0.4
c-0.3,0.2-0.5,0.6-0.5,1.1c0,0.4,0.2,0.7,0.5,1c0.4,0.3,1.1,0.6,2.1,0.9l3.6,0.8c1,0.3,1.8,0.7,2.2,1.2c0.4,0.5,0.6,1.2,0.6,2.2
v37.1c0,4.1,1.1,7.2,3.4,9.2c2.3,2,5.3,3,9,3c2.3,0,4.3-0.4,6.1-1.2c1.8-0.8,3.2-1.8,4.2-2.9c1-1.2,1.5-2.3,1.5-3.5
C258.4,110.5,258.3,110.1,258.1,109.9z"/>
<path class="st0" d="M407.3,76l-2-1.9l2.9-2.5c4.6-4,8.5-6.8,11.9-8.4c3.4-1.6,6.7-2.4,9.9-2.4c5,0,9,1.5,12,4.6
c3,3.1,4.5,7.3,4.5,12.7v32.5c0,1,0.3,1.8,0.8,2.4c0.5,0.6,1.3,1,2.2,1.2l2.9,0.6c0.6,0.2,1.1,0.4,1.4,0.6c0.3,0.3,0.5,0.6,0.5,1
c0,0.4-0.2,0.8-0.5,1.1c-0.3,0.3-0.9,0.4-1.6,0.4h-19.9c-1.5,0-2.2-0.5-2.2-1.6c0-0.8,0.6-1.3,1.9-1.6l3.3-0.6
c1-0.2,1.7-0.6,2.1-1.1c0.4-0.6,0.7-1.3,0.7-2.3V79.4c0-4.5-1.1-7.8-3.3-10c-2.2-2.2-5.2-3.3-8.9-3.3c-2.2,0-4.7,0.6-7.3,1.8
c-2.6,1.2-5.5,3.1-8.6,5.8L407.3,76z"/>
<path class="st0" d="M511.4,100.7c-0.2-0.4-0.6-0.5-1.1-0.5c-0.4,0-0.7,0.1-0.9,0.3c-0.3,0.2-0.5,0.6-0.8,1.2c-1,3.3-3,6-6.2,8.2
s-6.9,3.2-11.3,3.2c-6.4,0-11.5-2.2-15.5-6.5c-4-4.3-6-10.5-6-18.6h36.3c1.9,0,3.3-0.4,4.3-1.2c1-0.8,1.4-2,1.4-3.5
c0-4.4-1-8.3-2.9-11.7c-1.9-3.3-4.6-6-8-7.9c-3.4-1.9-7.4-2.9-11.9-2.9c-5.6,0-10.4,1.3-14.5,4c-4.1,2.7-7.3,6.2-9.5,10.7
c-2.2,4.5-3.4,9.6-3.4,15.2c0,5.8,1.1,10.8,3.4,15c2.3,4.3,5.4,7.6,9.6,9.9s8.9,3.5,14.4,3.5c4.2,0,7.9-0.8,11.2-2.3s6-3.6,8.1-6.2
c2.1-2.6,3.2-5.5,3.6-8.6C511.7,101.5,511.6,101,511.4,100.7z M471.8,75.5c1.5-3.5,3.6-6.2,6.4-8.2c2.7-2,5.9-3,9.5-3
c4.5,0,8.1,1.6,10.8,4.9c2.7,3.3,4.1,7.6,4.1,13c0,1.7-1,2.5-2.9,2.5h-30C469.9,81.3,470.6,78.2,471.8,75.5z"/>
<path class="st1" d="M17.4,97.3"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="277.5667" y1="76.1994" x2="294.7258" y2="150.1156">
<stop offset="0" style="stop-color:#F5C26B"/>
<stop offset="1" style="stop-color:#D59040"/>
</linearGradient>
<path class="st2" d="M532,80.7c-38.5,17.9-77.7,27-119.2,27.8c-44.6,0.9-87.5-7.8-129-16.1c-39.1-7.8-79.6-15.9-120.9-15.9
c-0.3,0-0.6,0-0.8,0c-46.7,0.1-89.8,10.9-131.8,32.9l1.9,3.5C73.5,91.2,116,80.6,162,80.5c0.3,0,0.6,0,0.8,0
c40.9,0,81.2,8.1,120.1,15.9c40,8,80.6,16.2,123.5,16.2c40.3,0,82.5-7.2,128-28.5C533.5,83,532.7,81.9,532,80.7z"/>
<linearGradient id="SVGID_00000136391255565502544090000014781660229579327890_" gradientUnits="userSpaceOnUse" x1="278.2577" y1="78.0403" x2="295.4174" y2="151.9591">
<stop offset="0" style="stop-color:#F5C26B"/>
<stop offset="1" style="stop-color:#D59040"/>
</linearGradient>
<path style="opacity:0.7;fill:url(#SVGID_00000136391255565502544090000014781660229579327890_);" d="M533,82.2
c-38.8,18.2-78.3,27.4-120.2,28.2c-44.6,0.9-87.5-7.8-129-16.1c-39.1-7.8-79.6-15.9-120.9-15.9c-0.3,0-0.6,0-0.8,0
c-46.7,0.1-89.8,10.9-131.8,32.9l1.9,3.5C73.5,93.2,116,82.6,162,82.5c0.3,0,0.6,0,0.8,0c40.9,0,81.2,8.1,120.1,15.9
c40,8,80.6,16.2,123.5,16.2c40.7,0,83.3-7.4,129.3-29.1C534.7,84.4,533.8,83.4,533,82.2z"/>
<path class="st0" d="M113.2,76l-1.9-1.9l2.9-2.5c4.6-4,8.5-6.8,11.9-8.4c3.4-1.6,6.7-2.4,10-2.4c5,0,9,1.5,12.1,4.6
c3,3.1,4.6,7.3,4.6,12.7v32.5c0,1,0.3,1.8,0.8,2.4c0.5,0.6,1.2,1,2.2,1.2l2.9,0.6c0.6,0.2,1.1,0.4,1.4,0.6c0.3,0.3,0.5,0.6,0.5,1
c0,0.4-0.2,0.8-0.5,1.1c-0.3,0.3-0.9,0.4-1.6,0.4h-19.8c-1.5,0-2.2-0.5-2.2-1.6c0-0.8,0.6-1.3,1.9-1.6l3.3-0.6
c1-0.2,1.7-0.6,2.1-1.1c0.4-0.6,0.7-1.3,0.7-2.3V79.4c0-4.5-1.1-7.8-3.4-10c-2.2-2.2-5.2-3.3-8.9-3.3c-2.2,0-4.7,0.6-7.3,1.8
c-2.6,1.2-5.5,3.1-8.7,5.8L113.2,76z"/>
<path class="st0" d="M220.9,111.7c-0.2-0.2-0.4-0.4-0.7-0.4c-0.3,0-0.6,0.1-0.8,0.3c-0.2,0.2-0.4,0.4-0.6,0.6
c-0.3,0.5-0.6,1-1.1,1.4c-0.4,0.5-1.2,0.7-2.3,0.7c-0.9,0-1.6-0.3-2.1-0.9c-0.5-0.6-0.8-1.7-0.8-3.1V76.6c0-4.8-1.5-8.6-4.6-11.5
c-3.1-2.9-7.7-4.3-14-4.3c-4.6,0-8.5,0.7-11.8,2.1c-3.3,1.4-5.8,3.2-7.6,5.5c-1.8,2.2-2.7,4.5-2.7,6.9c0,1.4,0.4,2.5,1.2,3.3
c0.8,0.8,1.9,1.3,3.3,1.3c1.6,0,2.8-0.4,3.8-1.1c0.9-0.8,1.4-1.9,1.4-3.4v-4.1c0-2.1,0.9-3.9,2.6-5.4c1.8-1.5,4.5-2.2,8.2-2.2
c3.8,0,6.7,1.1,8.8,3.2c2,2.1,3.1,5.3,3.1,9.4v15.5c-1.4-0.5-2.9-0.9-4.6-1.3c-2.4-0.6-5.1-0.8-7.9-0.8c-7.1,0-12.6,1.5-16.6,4.5
c-4,3-6,6.7-6,11.2c0,4.3,1.6,7.6,4.7,10.1c3.1,2.4,7.1,3.7,12,3.7c4.2,0,8.2-0.9,11.9-2.8c2.6-1.3,4.9-2.9,6.9-4.9
c0.1,2.5,0.8,4.4,2.2,5.6c1.5,1.4,3.6,2.1,6.3,2.1c1.8,0,3.2-0.3,4.4-1c1.2-0.7,2.1-1.5,2.7-2.5c0.6-1,0.9-2,0.9-2.9
C221.2,112.3,221.1,112,220.9,111.7z M188.9,114.8c-3.2,0-5.8-0.9-7.9-2.8c-2.1-1.8-3.1-4.5-3.1-7.9c0-3.6,1.3-6.4,3.9-8.5
c2.6-2.1,6.2-3.1,10.9-3.1c2.4,0,4.7,0.3,6.8,0.8c1.6,0.4,3.1,0.9,4.6,1.4v13.5c-1.8,1.8-3.8,3.2-6,4.3
C195.2,114.1,192.1,114.8,188.9,114.8z"/>
<path class="st0" d="M297.5,64.3c-3.2,0-5.8,0.9-7.7,2.6c-1.9,1.7-2.9,4-2.9,6.9c0,1.8,0.4,3.3,1.3,4.7c0.9,1.4,2.3,2.7,4.5,3.8
c2.1,1.1,5.1,2.2,8.9,3.3c4.9,1.2,8.8,2.8,11.7,4.6s5,3.8,6.2,6c1.2,2.2,1.8,4.6,1.8,7.3c0,4.6-1.7,8.4-5,11.3
c-3.4,2.9-8.1,4.4-14.2,4.4c-2.3,0-4.3-0.2-5.8-0.7c-1.5-0.4-2.8-0.9-3.9-1.3c-1.1-0.4-2-0.6-2.8-0.6c-0.8,0-1.3,0.2-1.7,0.7
c-0.4,0.4-0.7,0.9-1,1.3c-0.3,0.4-0.8,0.6-1.4,0.6c-0.4,0-0.7-0.1-1-0.4c-0.2-0.3-0.5-0.7-0.8-1.3l-4.8-13.2c-0.3-0.8-0.4-1.4-0.3-2
c0.1-0.6,0.5-1,1-1.2c0.6-0.2,1.1-0.2,1.5,0c0.4,0.3,0.8,0.7,1.1,1.2c1.8,3.5,3.7,6.2,5.7,8.2c2.1,2,4.3,3.3,6.6,4.1
c2.3,0.8,4.7,1.1,7.2,1.1c4.2,0,7.3-0.9,9.4-2.7c2.1-1.8,3.1-4.2,3.1-7.1c0-1.7-0.5-3.3-1.4-4.8c-0.9-1.5-2.5-2.9-4.7-4.1
c-2.3-1.3-5.4-2.5-9.5-3.5c-4.6-1.2-8.3-2.6-11-4.2c-2.7-1.6-4.7-3.5-5.9-5.7c-1.2-2.2-1.8-4.7-1.8-7.6s0.7-5.4,2.1-7.7
c1.4-2.3,3.4-4,6.1-5.3c2.6-1.3,5.8-2,9.4-2c2.3,0,4.2,0.2,5.7,0.7c1.5,0.5,2.7,1,3.7,1.4c1,0.5,1.8,0.7,2.5,0.7
c0.8,0,1.4-0.2,1.8-0.7c0.4-0.5,0.7-1,1-1.4c0.3-0.5,0.8-0.7,1.3-0.7c0.3,0,0.6,0.1,0.9,0.3c0.3,0.2,0.5,0.6,0.6,1.2l4,12.5
c0.3,0.9,0.4,1.7,0.4,2.4s-0.4,1.2-1,1.5c-0.6,0.2-1,0.1-1.5-0.2s-0.9-1-1.3-1.8c-2.1-4.4-4.8-7.6-8-9.5
C304.4,65.2,301,64.3,297.5,64.3z"/>
<path class="st0" d="M414.7,114.7l-2.9-0.6c-0.9-0.2-1.6-0.6-2.2-1.2c-0.5-0.6-0.8-1.3-0.8-2.3V78.1c0-5.4-1.5-9.6-4.5-12.7
c-3-3.1-7-4.6-12-4.6c-3.2,0-6.5,0.8-9.9,2.4c-3.2,1.5-7,4.2-11.2,7.8v-7.2c0-0.8-0.2-1.4-0.6-1.8s-1-0.6-1.8-0.6
c-0.4,0-1,0.1-1.7,0.2c-0.7,0.1-1.5,0.4-2.5,0.8l-8.7,2.9c-0.8,0.2-1.3,0.5-1.6,0.8c-0.3,0.3-0.4,0.6-0.4,1c0,0.4,0.1,0.7,0.4,0.9
c0.3,0.2,0.7,0.4,1.4,0.4l5,0.1c0.7,0,1.2,0.2,1.5,0.6c0.3,0.4,0.5,0.9,0.5,1.7v40.1c0,1-0.2,1.7-0.7,2.2c-0.5,0.5-1.1,0.8-2,1
l-3.3,0.7c-0.6,0.2-1.1,0.4-1.4,0.6c-0.3,0.3-0.5,0.6-0.5,1c0,0.4,0.2,0.8,0.5,1.1c0.4,0.3,0.9,0.4,1.6,0.4h19.7
c1.5,0,2.2-0.5,2.2-1.6c0-0.8-0.6-1.3-1.9-1.6l-3.1-0.6c-0.8-0.2-1.5-0.5-1.9-1c-0.5-0.5-0.7-1.2-0.7-2.1V74.6l1.2-1
c3.2-2.6,6-4.6,8.6-5.8c2.6-1.2,5-1.8,7.3-1.8c3.7,0,6.6,1.1,8.9,3.3s3.3,5.6,3.3,10v31.1c0,1-0.2,1.8-0.7,2.3
c-0.4,0.6-1.1,0.9-2.1,1.1l-3.3,0.6c-1.2,0.3-1.9,0.9-1.9,1.6c0,1,0.7,1.6,2.2,1.6h19.7c1.4,0,2.1-0.5,2.1-1.6
C416.5,115.6,415.9,115,414.7,114.7z"/>
<path class="st0" d="M269.5,42.7c2.2,0,3.8,0.6,5,1.7c1.1,1.1,1.7,2.6,1.7,4.3c0,1.9-0.7,3.4-2,4.6c-1.4,1.2-3,1.8-5,1.8
c-2.4,0-4.3-0.9-5.8-2.6c-1.5-1.7-2.3-4.6-2.3-8.5c0-3.8,0.8-7,2.3-9.6c1.5-2.6,3.3-4.6,5.4-5.9c2.1-1.4,3.8-2.2,5.4-2.4
c0.7-0.1,1.2-0.1,1.6,0.1c0.4,0.2,0.6,0.5,0.7,1c0,0.4-0.1,0.8-0.4,1.1c-0.3,0.4-1,0.8-2,1.2c-1.7,0.7-3.1,1.6-4.4,2.7
c-1.3,1.1-2.2,2.4-2.9,3.8c-0.7,1.4-1,2.7-1,4c0,0.9,0.2,1.6,0.7,2c0.4,0.5,1.2,0.7,2.4,0.7H269.5z"/>
<circle class="st4" cx="33.2" cy="111" r="5.2"/>
<g>
<circle class="st5" cx="553" cy="67.7" r="24.7"/>
<circle class="st5" cx="553" cy="67.7" r="17.7"/>
<circle class="st6" cx="553" cy="67.7" r="11.1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.5 KiB

17
dev/ticktes.md Normal file
View file

@ -0,0 +1,17 @@
Mittel: Orientierung und Klarheit
Zwei Dinge heißen "Einstellungen". Der Regler-Button unten links öffnet das Wellen-Panel mit Titel "Einstellungen". Das Benutzermenü hat einen Eintrag "Einstellungen", der ein Modal mit Titel "Allgemein" öffnet. Vorschlag: Wellen-Panel in "Darstellung" umbenennen, Modal in "Einstellungen".
Das Wellen-Panel ist ein Entwickler-Panel. Vierzehn Slider mit technischen Namen wie "Fächerbreite", "Feinheit", "Wellen-Amp" oder "Feinheit 5.0 bis 10.0". Presets, der eigentliche Einstieg für Endnutzer, stehen ganz unten. Hintergrundbilder sind Buttons mit Zahlen 1 bis 10 statt Vorschaubilder. Vorschlag: Presets und Hintergrundbilder nach oben, alle Slider unter "Erweitert" einklappen.
Tote Menüeinträge. "Plan upgraden", "Personalisierung" und das gesamte Hilfe-Untermenü in UserMenu.vue tun beim Klick nichts. Entweder ausblenden oder als "bald verfügbar" kennzeichnen. Dazu der Tippfehler "Tastaturkuerzel" und ein fest orangefarbener Avatar, der die Akzentfarbe ignoriert.
Platzhalter und wirkungslose Optionen im Einstellungs-Modal. Zwei von drei Tabs zeigen nur "Kommt bald". Die Sprachauswahl hat keine Wirkung, es gibt kein i18n im Projekt. Die FPS-Anzeige ist eine Entwickleroption unter "Allgemein". Akzentfarben heißen "Red", "Orange" in einer deutschen Oberfläche.
Farbwahl beim Emotion-Slider ist unklar. Der Button "Aus Verlauf" setzt die eigene Punktfarbe zurück, das sagt das Label nicht. "Automatisch" wäre verständlicher.
Fehlerzustand beim Speichern ohne Ausweg. "Nicht gespeichert" erscheint als 11-Pixel-Text ohne Möglichkeit, es erneut zu versuchen.
Login, Touch und Barrierefreiheit
Login-Seite mischt Sprachen: "Welcome back", "Login", "Remember me" neben deutschen Texten. Der Haken "Remember me" ist nicht angebunden, login() in auth.js:134 kennt den Wert nicht. Das Feld heißt "E-Mail", ist aber ein Dropdown.
Kontrast des Login-Buttons. Weiße Schrift auf dem Gelbgrün-Verlauf liegt weit unter dem WCAG-Mindestkontrast. Dunkle Schrift oder ein dunklerer Verlauf würden das lösen.
Touch-Ziele zu klein. Zoom-Buttons haben 28 Pixel, die Zoom-Spur ist 3 Pixel breit. Monats-Labels haben 12 Pixel bei 35 Prozent Deckkraft. Empfohlen sind 44 Pixel.
Icon-Buttons ohne Beschriftung. Header-Nutzer, Regler-Button und Plus-Button haben kein aria-label. Die Dots selbst sind gut gelöst mit role und aria-label.
Kleinigkeiten im Code: Die Klasse für ausgeblendete Dots setzt Deckkraft auf 1, die Hervorhebung beim Auswählen wirkt daher nur über die Skalierung. Der Ghost-Dot wird nie angezeigt, weil beim Anlegen sofort eine Editing-ID gesetzt ist.

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Builds the graphic LifeWave backgrounds as SVG and renders them to JPEG.
Square 2048 px, so `background-size: cover` crops them evenly in portrait and
landscape. Palette: ink #292a2b / #1a1b1c, gold #f5c26b / #d59040, paper
#f7f3ea. Deterministic: same seed, same file.
Usage: python3 build_graphic.py [out_dir] (default dev/backgrounds/graphic)
"""
import os, random, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, 'dev', 'backgrounds', 'graphic')
S = 2048
GRAIN = '''
<filter id="grain" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
<feComponentTransfer><feFuncA type="linear" slope="{alpha}"/></feComponentTransfer>
</filter>'''
def svg(defs, body):
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{S}" height="{S}" viewBox="0 0 {S} {S}">\n<defs>{defs}</defs>\n{body}\n</svg>\n'
def ink():
defs = '''
<radialGradient id="g" cx="50%" cy="46%" r="72%">
<stop offset="0" stop-color="#2f3032"/><stop offset="0.65" stop-color="#232425"/><stop offset="1" stop-color="#161718"/>
</radialGradient>''' + GRAIN.format(alpha=0.06)
body = f'<rect width="{S}" height="{S}" fill="url(#g)"/><rect width="{S}" height="{S}" filter="url(#grain)" opacity="0.5"/>'
return svg(defs, body)
def gold_dust():
rnd = random.Random(2026)
specks = []
for _ in range(5200):
# Denser and brighter towards the bottom; the middle band stays calm.
y = S * (1 - rnd.random() ** 0.45)
x = rnd.random() * S
r = rnd.choice([1.0, 1.3, 1.6, 2.0, 2.6, 3.4, 4.2])
depth = y / S
o = min(0.95, (0.08 + 1.1 * depth ** 1.6) * rnd.uniform(0.35, 1.0))
c = '#f5c26b' if rnd.random() < 0.7 else '#d59040'
specks.append(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{o:.2f}"/>')
defs = '''
<radialGradient id="g" cx="50%" cy="40%" r="75%">
<stop offset="0" stop-color="#2c2d2f"/><stop offset="1" stop-color="#151617"/>
</radialGradient>
<linearGradient id="warm" x1="0" y1="0" x2="0" y2="1">
<stop offset="0.5" stop-color="#d59040" stop-opacity="0"/><stop offset="1" stop-color="#d59040" stop-opacity="0.22"/>
</linearGradient>
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="2"/></filter>''' + GRAIN.format(alpha=0.05)
body = (f'<rect width="{S}" height="{S}" fill="url(#g)"/>'
f'<rect width="{S}" height="{S}" fill="url(#warm)"/>'
f'<g filter="url(#soft)">{"".join(specks[:2200])}</g><g>{"".join(specks[2200:])}</g>'
f'<rect width="{S}" height="{S}" filter="url(#grain)" opacity="0.4"/>')
return svg(defs, body)
def horizon():
defs = '''
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0f1013"/><stop offset="0.52" stop-color="#1b1c1f"/>
<stop offset="0.66" stop-color="#2e2415"/><stop offset="0.74" stop-color="#241c12"/><stop offset="1" stop-color="#12100b"/>
</linearGradient>
<filter id="blur" x="-50%" y="-400%" width="200%" height="900%"><feGaussianBlur stdDeviation="120"/></filter>''' + GRAIN.format(alpha=0.05)
body = (f'<rect width="{S}" height="{S}" fill="url(#sky)"/>'
f'<ellipse cx="{S/2}" cy="{S*0.665:.0f}" rx="{S*1.1:.0f}" ry="60" fill="#d59040" opacity="0.32" filter="url(#blur)"/>'
f'<ellipse cx="{S/2}" cy="{S*0.665:.0f}" rx="{S*0.7:.0f}" ry="14" fill="#f5c26b" opacity="0.22" filter="url(#blur)"/>'
f'<rect width="{S}" height="{S}" filter="url(#grain)" opacity="0.5"/>')
return svg(defs, body)
def paper():
defs = '''
<radialGradient id="g" cx="50%" cy="45%" r="75%">
<stop offset="0" stop-color="#f9f5ec"/><stop offset="0.7" stop-color="#f3ecdd"/><stop offset="1" stop-color="#e8dfcc"/>
</radialGradient>
<filter id="fibre" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.02 0.35" numOctaves="3" seed="3" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
<feComponentTransfer><feFuncA type="linear" slope="0.10"/></feComponentTransfer>
</filter>''' + GRAIN.format(alpha=0.05)
body = (f'<rect width="{S}" height="{S}" fill="url(#g)"/>'
f'<rect width="{S}" height="{S}" filter="url(#fibre)" opacity="0.6"/>'
f'<rect width="{S}" height="{S}" filter="url(#grain)" opacity="0.5"/>')
return svg(defs, body)
def charcoal():
rnd = random.Random(11)
smudges = ''.join(
f'<ellipse cx="{rnd.uniform(0.05, 0.95) * S:.0f}" cy="{rnd.choice([rnd.uniform(0.02, 0.22), rnd.uniform(0.78, 0.98)]) * S:.0f}" '
f'rx="{rnd.uniform(220, 520):.0f}" ry="{rnd.uniform(40, 110):.0f}" fill="#f7f3ea" opacity="{rnd.uniform(0.025, 0.06):.3f}" '
f'transform="rotate({rnd.uniform(-12, 12):.1f} {S/2} {S/2})" filter="url(#blur)"/>'
for _ in range(9))
defs = '''
<radialGradient id="g" cx="50%" cy="50%" r="75%">
<stop offset="0" stop-color="#2d2e30"/><stop offset="1" stop-color="#1d1e1f"/>
</radialGradient>
<filter id="chalk" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="4" seed="5" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
<feComponentTransfer><feFuncA type="table" tableValues="0 0 0.02 0.10 0.16"/></feComponentTransfer>
</filter>
<filter id="blur" x="-50%" y="-100%" width="200%" height="300%"><feGaussianBlur stdDeviation="40"/></filter>''' + GRAIN.format(alpha=0.08)
body = (f'<rect width="{S}" height="{S}" fill="url(#g)"/>'
f'<rect width="{S}" height="{S}" fill="#f7f3ea" filter="url(#chalk)" opacity="0.5"/>'
f'{smudges}'
f'<rect width="{S}" height="{S}" filter="url(#grain)" opacity="0.6"/>')
return svg(defs, body)
BACKGROUNDS = [
('01-tinte', ink), ('02-goldstaub', gold_dust), ('03-horizont', horizon),
('04-papier', paper), ('07-kohle', charcoal),
]
if __name__ == '__main__':
os.makedirs(OUT, exist_ok=True)
for name, fn in BACKGROUNDS:
svg_path = os.path.join(OUT, f'{name}.svg')
png_path = os.path.join(OUT, f'{name}.png')
jpg_path = os.path.join(OUT, f'{name}.jpg')
open(svg_path, 'w').write(fn())
subprocess.run(['rsvg-convert', '-w', str(S), '-h', str(S), svg_path, '-o', png_path], check=True)
subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', png_path, '-q:v', '3', jpg_path], check=True)
os.remove(png_path)
print(jpg_path, os.path.getsize(jpg_path) // 1024, 'KB')

View file

@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Generates the photographic LifeWave backgrounds with Gemini (Nano Banana 2).
Square, because the app crops with `cover` in both orientations. Every
prompt asks for an empty middle band and no focal point: the wave and its
dots are the subject, the background only sets the mood.
Usage: python3 generate_photo.py [name ...] (default: all five)
"""
import os, sys, time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lifeline-images'))
from gemini import generate # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
OUT = os.path.join(ROOT, 'dev', 'backgrounds', 'photo')
COMMON = (' Square format, fills the frame edge to edge. No text, no watermark, no people, no animals, '
'no buildings, no objects. The centre band of the image is calm and almost uniform, because a '
'thin glowing line will be drawn over it later. Photographic, subdued, low contrast, fine grain.')
PROMPTS = {
# Second pass: the graphic ideas rendered as photographed material, so
# they carry inner detail without gaining a subject.
'01-tinte': (
'Macro photograph of the surface of deep charcoal-black handmade paper washed with black ink, so close that '
'the paper extends beyond every edge of the frame, no sheet edges, no border, no background. '
'Subtle mottled variations of anthracite and graphite, faint cotton fibres visible on close inspection, '
'a soft darker vignette towards the edges, raking light from the upper left giving the surface a gentle '
'matte relief. Nothing written on it, no objects.' + COMMON),
'02-goldstaub': (
'Macro photograph of fine gold dust and a few tiny gold leaf flakes scattered on dark anthracite velvet. '
'The dust gathers along the bottom edge and thins out quickly upwards, the upper two thirds of the frame '
'are almost empty dark velvet with only a handful of distant specks. Warm gold reflections, very shallow '
'depth of field so most specks are soft bokeh. Elegant, dark, sparse.' + COMMON),
'03-horizont': (
'Minimalist photograph of a vast empty plain at the very end of dusk. Black featureless land in the lower '
'third, a deep graphite night sky above, and between them only a thin, soft band of warm amber and gold '
'afterglow along the horizon at two thirds of the height. No sun, no clouds, no trees, no lights. '
'Cinematic, very dark, the glow is the only colour.' + COMMON),
'04-papier': (
'Macro photograph of the surface of warm cream handmade cotton paper, so close that the paper extends beyond '
'every edge of the frame, no sheet edges, no deckle edge, no border, no background. Soft uneven surface with '
'fine fibres, lit with raking light from the side so the texture shows as gentle relief, a subtle '
'darker vignette in the corners. No writing, no lines, no objects, no folds.' + COMMON),
'05-leinen': (
'Photograph of heavy dark charcoal-grey linen with a visible slub weave, lit from the side so the threads '
'catch a faint warm sheen. The fabric lies flat and even across the whole frame, no folds, no creases, no '
'seams, only the weave itself with a very gentle variation of light. Rich, tactile, dark, low contrast.' + COMMON),
'06a-aquarell-warm': (
'Extreme close-up of a watercolour wash on cold-pressed paper, the paper runs beyond every edge of the '
'frame, no border, no tape marks, no stripes, no bands, one continuous sheet. Blooms of warm sepia, golden '
'ochre and a little dusty indigo pigment gather in the corners with visible granulation and soft '
'cauliflower edges, the middle stays pale warm cream. No shapes, no drips, no splatter.' + COMMON),
'06b-aquarell-dunkel': (
'Extreme close-up of an ink wash on heavy paper, the paper runs beyond every edge of the frame, no border. '
'Deep indigo and graphite ink bleeds in from the top and bottom edges with visible granulation, the '
'middle band softens into a muted grey-blue wash, tiny specks of gold pigment caught in the darker '
'areas. Moody, dark overall. No shapes, no drips.' + COMMON),
'07-kohle': (
'Photograph of a dark slate chalkboard surface, matte anthracite with a faint haze of chalk dust and the '
'ghost traces of wiped chalk strokes near the top and bottom edges, the centre almost clean. Fine mineral '
'texture of the slate visible up close, soft even light.' + COMMON),
'08-nebel': (
'Photograph of gentle hills at dawn in dense morning fog, almost monochrome grey-blue, dark toned. '
'A faint treeline is barely visible at the very bottom, the rest of the frame is soft, empty fog with a '
'slight brightening towards the top. No sun, no sky detail.' + COMMON),
'09-daemmerung': (
'Long-exposure photograph of a calm sea at dusk. The horizon sits in the lower third. Deep blue-grey '
'sky above fading into a narrow warm amber band right at the horizon, perfectly smooth water below, '
'darker than the sky. No sun disc, no detailed clouds, no boats, no shore.' + COMMON),
'10-nachthimmel': (
'Photograph of a deep midnight-blue night sky turning to black, with faint thin wisps of high cloud. '
'No stars, no moon, no ground, no horizon, no light source. A smooth dark gradient with a hint of '
'texture from the clouds.' + COMMON),
}
if __name__ == '__main__':
names = sys.argv[1:] or list(PROMPTS)
os.makedirs(OUT, exist_ok=True)
for name in names:
out = os.path.join(OUT, f'{name}.jpg')
for size in ('2K', '1K'):
try:
generate(PROMPTS[name], out=out, aspect='1:1', size=size)
print(out, size, os.path.getsize(out) // 1024, 'KB')
break
except RuntimeError as error:
print(name, size, 'failed:', str(error)[:200])
time.sleep(2)

View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Generate the age anchor portraits for every character of a lifeline.
Base portrait: text-only (children optionally with the parents as references).
Each further anchor: references = base portrait + previous anchor, so identity
stays pinned to the base while aging progresses step by step.
Existing files are kept; delete a file to regenerate it.
"""
import json, os, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gemini import generate, ROOT
lifeline = sys.argv[1]
bible = json.load(open(f'{ROOT}/backend/database/data/lifelines/characters/{lifeline}.json', encoding='utf-8'))
out_root = f'{ROOT}/backend/storage/app/private/lifeline-images/characters/{lifeline}'
PORTRAIT = bible.get('portrait', "Head-and-shoulders portrait, looking straight into the camera, neutral bright daylight, plain light wall background, plain clothing, natural skin, no retouching, no text.")
def era_for(year):
for span, desc in bible['era'].items():
a, b = map(int, span.split('-'))
if a <= year <= b: return desc
return ''
def preview(png):
subprocess.run(['php', '-r', '$im=imagecreatefromstring(file_get_contents($argv[1])); imagejpeg(imagescale($im,500),$argv[2],80);', png, png.replace('.png', '.preview.jpg')])
def run(key, c):
portrait = c.get('portrait', PORTRAIT)
d = f'{out_root}/{key}'; os.makedirs(d, exist_ok=True)
base_age = c.get('base', c['anchors'][0])
base = f'{d}/{base_age}.png'
if not os.path.exists(base):
desc = c.get('baseDescription', c['description'])
year = c['birthYear'] + base_age
prompt = f"Photo from {year} ({era_for(year)}): {portrait} {c.get('ageLabels', {}).get(str(base_age), f'A {base_age}-year-old')} named {c['name']}: {desc}"
generate(prompt, [], base, aspect='3:4'); preview(base)
print(f'{key} {base_age} (base) ok', flush=True)
prev = base
# anchors older than base, ascending; then anchors younger than base, descending
older = [a for a in c['anchors'] if a > base_age]
younger = sorted([a for a in c['anchors'] if a < base_age], reverse=True)
for seq in (older, younger):
prev = base
for age in seq:
png = f'{d}/{age}.png'
if os.path.exists(png):
prev = png; continue
year = c['birthYear'] + age
diff = age - (int(os.path.basename(prev)[:-4]))
direction = 'older' if diff > 0 else 'younger'
change = c['aging'].get(str(age), '')
if age == 0 and c.get('parents'):
parents = c.get('parents', [])
shown = 'the child of the person shown' if len(parents) == 1 else 'the child of the two people shown' if parents else ''
prompt = (f"Photo from {year}: a newborn baby a few days old, {shown}. {change}. "
f"Close-up of the baby's face wrapped in a white blanket, soft hospital light, {bible['style']}")
refs = []
for pk in parents:
pc = bible['characters'][pk]
page = year - pc['birthYear']
refs.append(f"{out_root}/{pk}/{min(pc['anchors'], key=lambda a: abs(a - page))}.png")
else:
label = c.get('ageLabels', {}).get(str(age), f"{age} years old")
prompt = (f"Photo from {year} ({era_for(year)}): {portrait} The exact same individual as in the reference photos, {c['name']}, "
f"now {label}, {abs(diff)} years {direction} than in the second reference photo. Keep every identifying feature "
f"unchanged: face shape, eyes, nose, mouth, ears and any moles, freckles or markings ({c['description']}). Age-related changes only: {change}.")
# From `adultBase` on, the identity reference is that adult anchor
# instead of the (teenage) base portrait.
ident = base
if c.get('adultBase') and age > c['adultBase'] and os.path.exists(f"{d}/{c['adultBase']}.png"):
ident = f"{d}/{c['adultBase']}.png"
refs = [ident, prev] if prev != ident else [ident]
generate(prompt, refs, png, aspect='3:4'); preview(png)
print(f'{key} {age} ok', flush=True)
prev = png
failed = []
for key, c in bible['characters'].items():
if not c.get('anchors'):
continue
try:
run(key, c)
except Exception as ex:
print(f'{key} FAILED, skipping remaining anchors of this character: {ex}', flush=True)
failed.append(key)
print('failed: ' + ', '.join(failed) if failed else 'all anchors ok')

View file

@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Build a local contact sheet (HTML) for a lifeline's generated images."""
import base64, html, json, os, sys
handle = sys.argv[1]
data = json.load(open(f'/workspace/backend/database/data/lifelines/{handle}.json', encoding='utf-8'))
d = f'/workspace/backend/storage/app/private/lifeline-images/{handle}'
MON = ['','Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']
cards = []
for e in data['events']:
slug = f"{e['age']:02d}-{e['month']:02d}"
p = f'{d}/{slug}.preview.jpg'
year = data['person']['birthYear'] + e['age']
img = f'<img src="data:image/jpeg;base64,{base64.b64encode(open(p,"rb").read()).decode()}">' if os.path.exists(p) else '<div class="missing">kein Bild</div>'
cards.append(f'<figure><div class="img">{img}</div><figcaption><b>{slug} · {MON[e["month"]]} {year}</b> · {html.escape(e["rating"])}<br><strong>{html.escape(e["title"])}</strong><br><small>{html.escape(e["note"])}</small></figcaption></figure>')
out = f'{d}/contact-sheet.html'
open(out, 'w', encoding='utf-8').write(f'''<!doctype html><meta charset="utf-8"><title>{html.escape(data["person"]["name"])} – Kontaktbogen</title>
<style>body{{font-family:system-ui;background:#111;color:#eee;margin:0;padding:20px}}h1{{font-weight:600}}
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:18px}}figure{{margin:0;background:#1c1c1c;border-radius:10px;overflow:hidden}}
.img{{aspect-ratio:4/3;background:#000;display:flex;align-items:center;justify-content:center}}img{{width:100%;height:100%;object-fit:contain}}
figcaption{{padding:10px 12px;font-size:13px;line-height:1.4}}small{{color:#aaa}}.missing{{color:#888}}</style>
<h1>{html.escape(data["person"]["name"])} – {len(cards)} Events</h1><div class="grid">{"".join(cards)}</div>''')
print(out)

View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Generate one key image per lifeline event with Nano Banana 2, using the
character bible and age anchors as references. Skips existing files.
python3 events.py nadja # all events
python3 events.py nadja 32-05 ... # only these slugs
python3 events.py nadja --dry-run # print prompts only
"""
import json, os, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gemini import generate, ROOT
lifeline = sys.argv[1]
only = {a for a in sys.argv[2:] if not a.startswith('--')}
dry = '--dry-run' in sys.argv
data = json.load(open(f'{ROOT}/backend/database/data/lifelines/{lifeline}.json', encoding='utf-8'))
bible = json.load(open(f'{ROOT}/backend/database/data/lifelines/characters/{lifeline}.json', encoding='utf-8'))
chars_dir = f'{ROOT}/backend/storage/app/private/lifeline-images/characters/{lifeline}'
out = f'{ROOT}/backend/storage/app/private/lifeline-images/{lifeline}'
os.makedirs(out, exist_ok=True)
MONTHS = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
def era_for(year):
for span, desc in bible['era'].items():
a, b = map(int, span.split('-'))
if a <= year <= b: return desc
return ''
def age_of(c, year, month):
"""Age in years; month-exact when the character has a birthMonth."""
if 'birthMonth' in c:
return ((year * 12 + month) - (c['birthYear'] * 12 + c['birthMonth'])) / 12
return year - c['birthYear']
def age_label(c, age):
if age < 2 and 'birthMonth' in c:
months = round(age * 12)
return 'a newborn baby' if months < 1 else f'a baby {months} months old'
return c.get('ageLabels', {}).get(str(int(age)), f'{int(age)} years old')
def anchor_for(key, year, month, forced=None):
c = bible['characters'][key]
age = age_of(c, year, month)
nearest = forced if forced in c['anchors'] else min(c['anchors'], key=lambda a: abs(a - age))
return f'{chars_dir}/{key}/{nearest}.png', age, nearest
import re
def detect_characters(e, year, month=1):
"""Characters named in the Bildmotiv (`note`), main character always first.
A character with a `diedYear` is not placed into scenes after it."""
text = e['note']
main = bible['characters'][lifeline]
others = []
for key, c in bible['characters'].items():
if key == lifeline: continue
if c.get('diedYear') and year > c['diedYear']: continue
if year < c['birthYear']: continue
aliases = c.get('aliases', [c['name']])
if not any(re.search(r'\b' + re.escape(a) + r"(s|')?\b", text) for a in aliases):
continue
# "<Name> fotografiert ..." / "<Name> macht ein Foto ..." puts that
# person behind the camera, not in the picture (a selfie is different).
behind_camera = any(
re.search(r'\b' + re.escape(a) + r'\b[^.;]{0,30}\b(fotografiert|macht ein Foto|macht ein Familienfoto|macht ein Gruppenfoto)\b', text)
or re.search(r'\baufgenommen von [^.;]{0,25}\b' + re.escape(a) + r'\b', text)
for a in aliases) and 'Selfie' not in text
if not behind_camera:
others.append(key)
# The main character is in the picture unless the motif is explicitly a
# photo *of* someone else ("Ein Selfie von Emil ...", "Ein Foto von Petra
# mit Clara", "Nina fotografiert Clara", a video-call screenshot of Daniel).
main_named = any(re.search(r'\b' + re.escape(a) + r"(s|')?\b", text) for a in main.get('aliases', [main['name']]))
other_names = [a for k in others for a in bible['characters'][k].get('aliases', [bible['characters'][k]['name']])]
photo_of_other = other_names and re.match(
r'^(Ein|Eine|Ein letztes|Ein altes)?\s*(Selfie|Foto|Handyfoto|Video-Standbild|Videocall-Screenshot|Video-Chat-Screenshot|Screenshot|Selfie-Video-Standbild)[^,.]*?\b(von|zeigt|zeigt einen|zeigt eine|mit)\s+(einen |eine |einem |sichtlich |erleichterten |besorgten )?(' + '|'.join(map(re.escape, other_names)) + r')\b', text)
photographer_of_other = other_names and re.match(r'^\w+ fotografiert (' + '|'.join(map(re.escape, other_names)) + r')\b', text)
# "beide" / "zu zweit" / "das Paar" without a name means the partner of
# that period; "Familie" means partner plus the children already born.
ym = f'{year:04d}-{month:02d}'
if re.search(r'\b(beide|zu zweit|Paar\w*|Familie\w*)\b', text):
for p in bible.get('partners', []):
if p['from'] <= ym <= p.get('to', '9999-12') and p['key'] not in others:
others.append(p['key'])
if re.search(r'\bFamilie\w*', text):
for k in bible.get('family', []):
c = bible['characters'][k]
if k not in others and year >= c['birthYear'] and not (c.get('diedYear') and year > c['diedYear']):
others.append(k)
# A band lifeline: "die Band" / "die vier" means all members of that period.
if bible.get('band') and re.search(r'\b(Band|die vier|alle vier|Nachtschicht|alle)\b', text):
for k in bible['band'] + [d['key'] for d in bible.get('bandDrummer', []) if d.get('from', '0000-00') <= ym <= d.get('to', '9999-12')]:
if k not in others:
others.append(k)
if not main.get('anchors'):
return others
if main_named or not (photo_of_other or photographer_of_other):
return [lifeline] + others
return others
def year_month_age(c, year, month):
return age_of(c, year, month)
def scene_for(e):
if e.get('imagePrompt'): return e['imagePrompt']
# "Heinz fotografiert das Auto, ..." -> the photographer is named separately
# so the model does not paint a person holding a camera into the scene.
m = re.match(r'^((?:Ein|Eine|Der|Die)\s+)?([\wäöüÄÖÜß]+(?:\s[\wäöüÄÖÜß]+)?) (fotografiert|macht ein Foto von|macht ein Familienfoto von|macht ein Gruppenfoto von) (.+)$', e['note'], re.S)
if m and e.get('photo') != 'Selfie':
who, rest = (m.group(1) or '') + m.group(2), m.group(4)
return (f"Photo taken by {who}, who is behind the camera and NOT visible in the picture. "
f"The following German description defines exactly what the picture shows: \"{rest}\"")
return (f"The following German description of the photo motif defines the scene exactly, including who took the photo "
f"(a photographer named in the description is not in the picture unless it is a selfie): \"{e['note']}\"")
def build(e):
year = data['person']['birthYear'] + e['age']
keys = e['imageCharacters'] if e.get('imageCharacters') is not None else detect_characters(e, year, e['month'])
refs, people = [], []
for i, k in enumerate(keys, 1):
path, age, nearest = anchor_for(k, year, e['month'], e.get('imageAnchorAges', {}).get(k))
c = bible['characters'][k]
aging = c['aging'].get(str(max((a for a in c['anchors'] if a <= age), default=nearest)), '')
refs.append(path)
people.append(f"Reference photo {i} is {c['name']}, now {age_label(c, age)} ({c['description']} {aging}).")
kind = ("Smartphone selfie taken at arm's length with the front camera: slight wide-angle distortion, the people close to the lens, one arm reaching out of frame; the phone itself is never visible in the picture."
if e.get('photo') == 'Selfie' else
"Candid photo taken by another person who stands behind the camera and is NOT visible in the picture: nobody in the picture holds a camera or a phone, no arm or hand with a camera in the foreground. Natural framing, subjects not posing stiffly.")
prompt = (f"Photo from {MONTHS[e['month']]} {year}, {era_for(year)}. {kind} Scene: {scene_for(e)}. "
f"{' '.join(people)} Every individual must match its reference photo exactly (same face shape, eyes, nose, mouth, hair or coat colour, distinguishing marks), "
f"only clothing, expression, pose and setting change. Clothing must NOT be copied from the reference photos: it changes from photo to photo and suits the scene and season. "
f"{bible.get('familyNote', '')} No other recognisable people in focus. No date stamp or printed date on the photo. {bible['style']}")
return prompt, refs
done = skipped = failed = 0
for e in data['events']:
slug = f"{e['age']:02d}-{e['month']:02d}"
if only and slug not in only: continue
png = f'{out}/{slug}.png'
if os.path.exists(png) and not dry:
skipped += 1; continue
prompt, refs = build(e)
if dry:
print(f'--- {slug} {e["title"]} refs: {[os.path.relpath(r, chars_dir) for r in refs]}'); continue
try:
generate(prompt, refs, png, aspect=e.get('imageAspect') or ('3:4' if e.get('photo') == 'Selfie' else '4:3'))
subprocess.run(['php', '-r', '$im=imagecreatefromstring(file_get_contents($argv[1])); imagejpeg(imagescale($im,700),$argv[2],80);', png, f'{out}/{slug}.preview.jpg'])
json.dump({'prompt': prompt, 'refs': refs, 'model': os.environ.get('LIFELINE_IMAGE_MODEL', 'gemini-3.1-flash-image')}, open(f'{out}/{slug}.json', 'w'), indent=1)
print(f'{slug} ok {e["title"]}', flush=True); done += 1
except Exception as ex:
print(f'{slug} FAILED {ex}', flush=True); failed += 1
if not dry: print(f'done={done} skipped={skipped} failed={failed}')

View file

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Minimal Gemini image client (Nano Banana 2) – REST only, no SDK.
Reads GOOGLE_API_KEY from backend/.env. Usage as a module:
from gemini import generate
generate(prompt, refs=[path,...], out='x.png', aspect='4:3')
"""
import base64, json, os, re, sys, time, urllib.request, urllib.error
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
MODEL = os.environ.get('LIFELINE_IMAGE_MODEL', 'gemini-3.1-flash-image')
def api_key():
for line in open(os.path.join(ROOT, 'backend', '.env')):
m = re.match(r'^GOOGLE_API_KEY=(.*)$', line.strip())
if m:
return m.group(1).strip().strip('"').strip("'")
sys.exit('GOOGLE_API_KEY missing in backend/.env')
def _mime(path):
with open(path, 'rb') as f:
head = f.read(8)
return 'image/png' if head.startswith(b'\x89PNG') else 'image/jpeg'
def generate(prompt, refs=(), out=None, aspect='4:3', size='1K', retries=3):
parts = []
for p in refs:
parts.append({'inline_data': {'mime_type': _mime(p), 'data': base64.b64encode(open(p, 'rb').read()).decode()}})
parts.append({'text': prompt})
body = {
'contents': [{'role': 'user', 'parts': parts}],
'generationConfig': {'responseModalities': ['IMAGE'], 'imageConfig': {'aspectRatio': aspect, 'imageSize': size}},
}
req = urllib.request.Request(
f'https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent',
data=json.dumps(body).encode(), method='POST',
headers={'Content-Type': 'application/json', 'x-goog-api-key': api_key()},
)
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=180) as r:
res = json.load(r)
except urllib.error.HTTPError as e:
msg = e.read().decode()[:400]
if e.code in (429, 500, 503) and attempt < retries - 1:
time.sleep(10 * (attempt + 1)); continue
raise RuntimeError(f'HTTP {e.code}: {msg}')
cands = res.get('candidates') or []
for part in (cands[0].get('content', {}).get('parts', []) if cands else []):
data = part.get('inlineData') or part.get('inline_data')
if data:
raw = base64.b64decode(data['data'])
if out:
open(out, 'wb').write(raw)
return raw
reason = (cands[0].get('finishReason') if cands else None) or res.get('promptFeedback')
if attempt < retries - 1:
time.sleep(5); continue
raise RuntimeError(f'no image returned: {reason} {json.dumps(res)[:300]}')
if __name__ == '__main__':
import argparse
a = argparse.ArgumentParser()
a.add_argument('prompt'); a.add_argument('--ref', action='append', default=[]); a.add_argument('--out', required=True)
a.add_argument('--aspect', default='4:3'); a.add_argument('--size', default='1K')
ns = a.parse_args()
generate(ns.prompt, ns.ref, ns.out, ns.aspect, ns.size); print(ns.out)

View file

@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Build 4x3 review montages (m1.jpg, m2.jpg, ...) of a lifeline's preview images."""
import json, os, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
handle = sys.argv[1]
cols = int(sys.argv[2]) if len(sys.argv) > 2 else 4
rows = int(sys.argv[3]) if len(sys.argv) > 3 else 3
per = cols * rows
d = json.load(open(f'{ROOT}/backend/database/data/lifelines/{handle}.json', encoding='utf-8'))
out = f'{ROOT}/backend/storage/app/private/lifeline-images/{handle}'
slugs = [f"{e['age']:02d}-{e['month']:02d}" for e in d['events'] if os.path.exists(f"{out}/{e['age']:02d}-{e['month']:02d}.preview.jpg")]
php = '''$slugs=json_decode($argv[1]);$cols=(int)$argv[3];$rows=(int)$argv[4];$w=(int)(1400/$cols);$h=(int)($w*0.85);$c=imagecreatetruecolor($w*$cols,$h*$rows);imagefill($c,0,0,0x202020);
foreach($slugs as $i=>$s){$im=imagecreatefromjpeg("$s.preview.jpg");$sw=imagesx($im);$sh=imagesy($im);$k=min($w/$sw,$h/$sh);$dw=(int)($sw*$k);$dh=(int)($sh*$k);imagecopyresampled($c,$im,($i%$cols)*$w+(int)(($w-$dw)/2),intdiv($i,$cols)*$h+(int)(($h-$dh)/2),0,0,$dw,$dh,$sw,$sh);imagestring($c,5,($i%$cols)*$w+6,intdiv($i,$cols)*$h+4,$s,0xFFFF00);}
imagejpeg($c,$argv[2],76);'''
for n in range(0, len(slugs), per):
subprocess.run(['php', '-r', php, json.dumps(slugs[n:n+per]), f'{out}/m{n//per+1:02d}.jpg', str(cols), str(rows)], cwd=out)
print(f'{(len(slugs)+per-1)//per} montages for {len(slugs)} images')

View file

@ -0,0 +1,119 @@
# Google-Gemini-Bild-API: Zugang
Stand: 25.09.2026
## Überblick
Die Key-Images der Demo-Lifelines entstehen über die Google Gemini API, Modell `gemini-3.1-flash-image` (Produktname „Nano Banana 2"). Der Zugang läuft ausschließlich über REST mit einem API-Key aus dem Google AI Studio – kein SDK, kein OAuth, kein Google-Cloud-Projekt-Setup nötig.
Genutzt wird der Zugang nur von den Entwickler-Werkzeugen unter `dev/tools/lifeline-images/`:
| Datei | Aufgabe |
| --- | --- |
| `gemini.py` | Einziger API-Client: liest den Key, baut den Request, speichert das Bild |
| `anchors.py` | Erzeugt die Altersanker je Figur (Porträt-Kette) |
| `events.py` | Erzeugt ein Bild pro Lifeline-Event mit Ankern als Referenz |
| `contact_sheet.py`, `montage.py` | Reine Sichtungs-Werkzeuge, ohne API-Zugriff |
Die Laravel-Anwendung selbst spricht nie mit der API. Sie importiert nur fertige Dateien per `php artisan lifelines:import-images <handle>`.
## Zugangsdaten
Der Key steht als `GOOGLE_API_KEY` in `backend/.env` – sonst nirgends. `.env` ist in `backend/.gitignore` ausgeschlossen, der Key liegt also nicht im Repository.
```
GOOGLE_API_KEY=<key aus dem AI Studio>
```
`gemini.py` liest die Datei direkt zeilenweise und schneidet Anführungszeichen ab; fehlt der Eintrag, bricht das Skript mit `GOOGLE_API_KEY missing in backend/.env` ab. Der Key wird nur als HTTP-Header `x-goog-api-key` gesendet, nie in eine URL, einen Log oder eine Ausgabe geschrieben.
Regeln für den Umgang:
- Der Key gehört nur in die lokale Entwicklungsumgebung. Auf Produktion wird er **nicht** benötigt – dort landen ausschließlich die fertigen PNG-Dateien und der Import-Befehl.
- Generierte Bilder und Anker liegen unter `backend/storage/app/private/lifeline-images/` und sind ebenfalls vom Repository ausgeschlossen.
- Der Key ist ein Zahlungsmittel: Wer ihn hat, generiert auf Kosten des hinterlegten Guthabens. Nicht in Tickets, Chats oder Screenshots weitergeben.
- Bei Verdacht auf Weitergabe: im AI Studio löschen und neu erzeugen (siehe letzter Abschnitt).
## Endpunkt und Request
Ein Aufruf ist ein POST auf `generateContent` des Bildmodells:
```
POST https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent
Content-Type: application/json
x-goog-api-key: <GOOGLE_API_KEY>
```
Der Body enthält einen einzigen Nutzer-Turn. Referenzbilder stehen als `inline_data` **vor** dem Text; ihre Reihenfolge ist bedeutsam, weil der Prompt sie als „Reference photo 1/2/3" anspricht:
```json
{
"contents": [{ "role": "user", "parts": [
{ "inline_data": { "mime_type": "image/jpeg", "data": "<base64>" } },
{ "text": "<Prompt>" }
]}],
"generationConfig": {
"responseModalities": ["IMAGE"],
"imageConfig": { "aspectRatio": "4:3", "imageSize": "1K" }
}
}
```
Parameter, die wir setzen:
| Feld | Werte | Bedeutung |
| --- | --- | --- |
| `responseModalities` | `["IMAGE"]` | Nur Bild zurück, kein Begleittext |
| `imageConfig.aspectRatio` | `4:3` bzw. `3:4` | Querformat; `3:4` für Selfies und Porträt-Anker |
| `imageConfig.imageSize` | `1K` | Auflösungsstufe |
| Modell-ID | Umgebungsvariable `LIFELINE_IMAGE_MODEL` | Überschreibt die Vorgabe `gemini-3.1-flash-image` |
Einen `seed` verwenden wir nicht mehr; falls doch gesetzt, akzeptiert die API nur Werte bis 1.000.000.
## Antwort verarbeiten
Das Bild steckt base64-kodiert im ersten Kandidaten:
```
candidates[0].content.parts[*].inlineData.data
```
Zwei Eigenheiten, die im Client berücksichtigt sind:
- Das Feld heißt je nach Antwort `inlineData` oder `inline_data` – beide Schreibweisen prüfen.
- Die gelieferten Daten sind faktisch JPEG, obwohl wir die Dateien mit der Endung `.png` ablegen. Beim Hochladen als Referenz wird der MIME-Typ deshalb aus den Magic Bytes bestimmt (`\x89PNG` → `image/png`, sonst `image/jpeg`) und nicht aus der Dateiendung.
Kommt kein Bild zurück, steht der Grund in `candidates[0].finishReason` oder in `promptFeedback`. Der Client wiederholt den Aufruf bis zu dreimal (5 s Pause; bei HTTP 429/500/503 gestaffelt 10/20 s) und wirft danach einen Fehler mit dem Grund im Text.
## Kontingente, Abrechnung, Fehlerbilder
Guthaben und Tageslimit sind zwei getrennte Grenzen. Beide haben wir beim Aufbau der Bildsets erreicht, sie sehen im Log sehr ähnlich aus:
| HTTP | Meldung | Ursache | Abhilfe |
| --- | --- | --- | --- |
| 402 | `Your prepayment credits are depleted` | Prepaid-Guthaben aufgebraucht | Im AI Studio unter Billing aufladen; wirkt sofort |
| 429 | `Quota exceeded for metric: generate_requests_per_model_per_day, limit: 100` | Tageslimit von 100 Anfragen pro Modell erreicht | Warten bis Mitternacht Pacific (ca. 9 Uhr MESZ) oder höhere Stufe beantragen |
| 200 mit `finishReason: IMAGE_OTHER` | `The model could not generate the image based on the prompt provided` | Inhaltsfilter des Modells, deterministisch pro Prompt | Prompt umformulieren – Wiederholen hilft nicht |
Zum Tageslimit: Es zählt Anfragen, nicht erfolgreiche Bilder. Fehlversuche und Wiederholungen zählen mit. Ein vollständiger Durchlauf für eine große Lifeline (Jonas, 252 Bilder) sprengt das Limit deshalb und muss über mehrere Tage laufen. Der aktuelle Verbrauch ist unter [ai.dev/rate-limit](https://ai.dev/rate-limit) einsehbar.
Zu `IMAGE_OTHER`: Der Filter reagiert auf Formulierungen, nicht auf Zufall. In unseren Sets waren die Auslöser unter anderem Rollenangaben wie „Singer of the band" (Prominenten-Filter), oberkörperfreie Referenzbilder und Kombinationen aus erschöpften Gesichtsbeschreibungen mit Bettszenen. Vorgehen: Prompt halbieren und die Hälften einzeln testen, bis die auslösende Passage feststeht.
## Key anlegen oder rotieren
1. [aistudio.google.com](https://aistudio.google.com/) mit dem Google-Konto öffnen, das die Rechnung trägt.
2. Unter **Get API key** einen Key für das gewünschte Projekt erzeugen. Der Wert wird nur einmal vollständig angezeigt.
3. Für das Bildmodell muss im Projekt Billing aktiv sein und Guthaben vorhanden – unter [ai.studio/projects](https://ai.studio/projects) zu prüfen. Ohne Guthaben antwortet die API mit HTTP 402.
4. Key in `backend/.env` als `GOOGLE_API_KEY=` eintragen. Kein `php artisan config:clear` nötig, da nur die Python-Werkzeuge ihn lesen.
5. Prüfen mit einem einzelnen Bild, zum Beispiel:
```
cd dev/tools/lifeline-images
python3 gemini.py "Ein roter Apfel auf einem weißen Tisch, Foto." --out /tmp/test.png
```
Erfolg gibt den Pfad aus; bei Problemen erscheint die HTTP-Fehlermeldung im Klartext.
6. Alten Key nach der Umstellung im AI Studio löschen.
Das Tageslimit hängt an Projekt und Abrechnungsstufe, nicht am einzelnen Key. Ein neuer Key im selben Projekt umgeht das Limit also nicht.

77
documentation/logging.md Normal file
View file

@ -0,0 +1,77 @@
# Fehler-Logging
Stand: 25.09.2026. Wo Fehler landen, wie man sie liest und was der Client automatisch meldet.
## Überblick
| Quelle | Ablage | Lesen |
|---|---|---|
| Browser-App (Warnungen, Fehler) | `backend/storage/logs/client-YYYY-MM-DD.log` | `tail -f storage/logs/client-*.log` oder `php artisan pail` |
| Browser-App, lokal | Einstellungen → Entwickler → Protokoll | im Browser, „Kopieren“ liefert JSON |
| Laravel (Exceptions, 5xx) | `backend/storage/logs/laravel.log` | `php artisan pail` |
Der Kanal `client` ist in `config/logging.php` definiert (täglich rotierend, 30 Tage, per `LOG_CLIENT_DAYS` und `LOG_CLIENT_LEVEL` einstellbar). Für den Server-Log empfiehlt sich auf dem Testserver `LOG_STACK=daily`, damit `laravel.log` nicht unbegrenzt wächst.
## Was der Client meldet
Der Logger liegt in `frontend/src/services/clientLog.js`. Er hält bis zu 200 Einträge im Speicher, spiegelt sie in die IndexedDB-Tabelle `clientLogs` (überlebt einen Reload) und schickt Warnungen und Fehler gebündelt an `POST /api/client-logs`:
- frühestens 5 Sekunden nach dem ersten neuen Eintrag, höchstens 50 Einträge pro Batch,
- nur online und nur für Konten mit Server-Anbindung (Demo-Nutzer behalten alles lokal),
- beim Verlassen der Seite mit `keepalive`, damit der letzte Batch noch rausgeht,
- gleiche Meldung aus gleicher Quelle innerhalb von 10 Sekunden erhöht einen Zähler statt eine Zeile zu erzeugen (`(×12)` im Log).
Erfasst wird automatisch:
- jede fehlgeschlagene API-Antwort außer 401 (`[api] PUT /settings → 429`, mit `Retry-After`),
- Netzwerkfehler ohne Antwort (`→ keine Antwort`, mit `online: false`),
- Bilder, die nach drei Versuchen nicht geladen werden konnten (`[image] …`, mit Status und Pfad),
- Einstellungen, die nicht übertragen werden konnten, mit Versuchszähler,
- Sync-Warteschlange, Sprachnotizen, Dexie-Fehler,
- unbehandelte Fehler: Vue `errorHandler`, `window.onerror`, `unhandledrejection`, fehlgeschlagene Ressourcen-Loads (`src/boot/errorLog.js`).
Jeder Eintrag trägt `user_id`, `app_version`, `session_id`, `user_agent`, `ip`, die Route in der App (`url`) und den Zeitpunkt im Browser (`occurred_at`).
### Eigene Meldungen
```js
import { clientLog } from 'src/services/clientLog'
clientLog.warn('scope', 'Was passiert ist', error) // Error oder flaches Objekt
clientLog.error('scope', 'Was passiert ist', { status: 500 })
clientLog.info('scope', 'nur lokal sichtbar')
```
`scope` ist ein kurzes Wort (`api`, `image`, `settings`, `sync`, `events`, `voice`), damit sich im Log nach Quelle filtern lässt. Kontext wird auf eine Ebene mit Skalaren abgeflacht, verschachtelte Objekte werden als JSON-String übernommen.
## Rate Limits
| Limiter | Grenze | Gilt für |
|---|---|---|
| `api` | 300/min pro Nutzer | alle Schreib- und Lesezugriffe der App außer Medien |
| `media` | 1200/min pro Nutzer | `GET /event-media/{id}/{variant}`, `GET /settings/media/background` |
| `uploads` | 30/min pro Nutzer | Medien- und Hintergrund-Uploads |
| `client-logs` | 30/min pro Nutzer | `POST /client-logs` |
Warum getrennt: Eine lange Timeline lädt pro sichtbarem Ereignis ein Vorschaubild. Vor dem 25.09.2026 zählten diese Bilder und die Einstellungs-Schreibvorgänge auf dasselbe Limit. Beim Scrollen wurde zusätzlich nach jeder Scroll-Pause sofort ein `PUT /settings` gesendet. Nach etwa einer Minute Scrollen antwortete die API mit 429, die Bilder blieben leer und die App meldete „Einstellungen konnten nicht gespeichert werden“.
## Verhalten des Clients bei 429 und Netzproblemen
- **Einstellungen** werden lokal sofort gespeichert, zum Server frühestens 1,5 Sekunden nach der letzten Änderung und nur, wenn sich der Inhalt geändert hat. Ein 429 oder Netzfehler wird still mit wachsendem Abstand wiederholt (3 s, 6 s, … bis 60 s) und beim Wieder-Online-Gehen. Nur eine Ablehnung des Inhalts (z. B. 422) zeigt die Meldung.
- **Bilder** laufen durch ein Gate mit höchstens 6 gleichzeitigen Anfragen. Nach einem 429 warten alle Bildanfragen die `Retry-After`-Zeit ab. Jedes Bild wird bis zu dreimal versucht, dann bleibt die Kachel leer (Platzhalter-Icon), statt ein kaputtes Bild zu zeigen. Während des Ladens pulsieren drei Punkte.
- **Sync-Warteschlange** (Events, Medien) hatte diese Logik bereits: 429 und 5xx gelten als vorübergehend und werden wiederholt.
## Nachschauen auf dem Server
```bash
# Client-Meldungen der letzten Minuten
tail -n 100 storage/logs/client-$(date +%F).log
# Nur Fehler eines Nutzers
grep '"user_id":7' storage/logs/client-*.log | grep ERROR
# Live, beide Kanäle
php artisan pail --timeout=0
```
Jede Laravel-Exception im Server-Log trägt seit dem 25.09.2026 `user_id`, `path` und `method` im Kontext (`bootstrap/app.php`), damit sich Server- und Client-Zeilen einander zuordnen lassen.

View file

@ -60,6 +60,24 @@ export default [
}
},
{
// Vitest runs with `globals: true`, so the test APIs are ambient.
files: [ 'test/vitest/**/*.js' ],
languageOptions: {
globals: {
describe: 'readonly',
it: 'readonly',
test: 'readonly',
expect: 'readonly',
vi: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly'
}
}
},
{
files: [ 'src-pwa/custom-service-worker.js' ],
languageOptions: {

File diff suppressed because it is too large Load diff

View file

@ -7,18 +7,24 @@
"type": "module",
"private": true,
"scripts": {
"lint": "eslint -c ./eslint.config.js \"src/**/*.{js,cjs,mjs,vue}\"",
"lint": "eslint -c ./eslint.config.js \"src/**/*.{js,cjs,mjs,vue}\" test/vitest",
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0",
"test": "vitest run",
"dev": "npm install && quasar dev",
"build": "quasar build",
"postinstall": "quasar prepare"
"dev:pwa": "npm install && quasar dev -m pwa",
"build": "quasar build -m pwa",
"build:spa": "quasar build",
"serve:pwa": "quasar serve dist/pwa --history --port 9100",
"postinstall": "quasar prepare",
"test:watch": "vitest",
"test:tz": "TZ=America/Los_Angeles vitest run test/vitest/calendar-date.spec.js test/vitest/components-timeline.spec.js && TZ=Pacific/Kiritimati vitest run test/vitest/calendar-date.spec.js test/vitest/components-timeline.spec.js && TZ=Europe/Berlin vitest run test/vitest/calendar-date.spec.js test/vitest/components-timeline.spec.js"
},
"dependencies": {
"@quasar/extras": "^1.16.4",
"dexie": "^4.3.0",
"pinia": "^3.0.1",
"quasar": "^2.16.0",
"register-service-worker": "^1.7.2",
"three": "^0.183.0",
"vue": "^3.4.18",
"vue-router": "^4.0.0",
@ -32,18 +38,30 @@
"devDependencies": {
"@eslint/js": "^9.14.0",
"@quasar/app-vite": "^2.1.0",
"@vitejs/plugin-vue": "^6.0.9",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/test-utils": "^2.5.1",
"autoprefixer": "^10.4.2",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"fake-indexeddb": "^6.2.5",
"globals": "^15.12.0",
"happy-dom": "^20.14.5",
"postcss": "^8.4.14",
"prettier": "^3.3.3",
"vite-plugin-checker": "^0.9.0"
"vite-plugin-checker": "^0.9.0",
"vitest": "^3.2.7",
"workbox-build": "^7.4.1",
"workbox-cacheable-response": "^7.4.1",
"workbox-core": "^7.4.1",
"workbox-expiration": "^7.4.1",
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1",
"workbox-strategies": "^7.4.1"
},
"engines": {
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 439 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 1,009 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Some files were not shown because too many files have changed in this diff Show more