first commit

This commit is contained in:
2026-04-11 18:57:00 +02:00
commit 58bcc8f0f3
29 changed files with 3290 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
final class EntryRepository
{
public function save(string $username, string $date, array $entry, array $evaluation): void
{
$path = $this->pathFor($username, $date);
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents($path, $this->toMarkdown($username, $date, $entry, $evaluation));
}
public function find(string $username, string $date): ?array
{
$path = $this->pathFor($username, $date);
if (!is_file($path)) {
return null;
}
return $this->parse((string) file_get_contents($path), $date);
}
public function all(string $username): array
{
$directory = $this->directoryFor($username);
if (!is_dir($directory)) {
return [];
}
$files = glob($directory . '/*.txt') ?: [];
rsort($files, SORT_STRING);
$entries = [];
foreach ($files as $file) {
$date = basename($file, '.txt');
$parsed = $this->parse((string) file_get_contents($file), $date);
if ($parsed !== null) {
$entries[] = $parsed;
}
}
return $entries;
}
private function directoryFor(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/days');
}
private function pathFor(string $username, string $date): string
{
return $this->directoryFor($username) . '/' . $date . '.txt';
}
private function parse(string $content, string $fallbackDate): ?array
{
$entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5),
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
'sleep_feeling' => (int) ($this->extract('/^- Schlafgefuehl:\s*(.+)$/m', $content) ?? 3),
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0),
'note' => $this->extractNote($content),
];
return $entry;
}
private function extract(?string $pattern, string $content): ?string
{
if ($pattern === null) {
return null;
}
if (!preg_match($pattern, $content, $matches)) {
return null;
}
return trim((string) ($matches[1] ?? ''));
}
private function extractNote(string $content): string
{
if (!preg_match('/^## Notiz\s*$\R?([\s\S]*)\z/m', $content, $matches)) {
return '';
}
return trim((string) ($matches[1] ?? ''));
}
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string
{
$lines = [
'<!-- mood-tracker:v1 -->',
'# Stimmungstracker',
'Datum: ' . $date,
'Benutzer: ' . normalize_username($username),
'',
'## Werte',
'- Stimmung: ' . $entry['mood'],
'- Energie: ' . $entry['energy'],
'- Stress: ' . $entry['stress'],
'- Schlafdauer: ' . $entry['sleep_hours'],
'- Schlafgefuehl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'],
'- Spaziergang: ' . $entry['walk_minutes'],
'',
'## Bewertung',
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
'- Urteil: ' . $evaluation['label'],
'',
'## Punktedetails',
'- Stimmung: ' . format_points((float) $evaluation['components']['mood']),
'- Energie: ' . format_points((float) $evaluation['components']['energy']),
'- Stress: ' . format_points((float) $evaluation['components']['stress']),
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
'- Schlafgefuehl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
'',
'## Notiz',
trim((string) $entry['note']),
'',
];
return implode("\n", $lines);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
final class LoginThrottle
{
private string $path;
private int $windowSeconds = 600;
private int $maxAttempts = 5;
public function __construct()
{
$this->path = storage_path('system/login-throttle.json');
}
public function tooManyAttempts(string $key): bool
{
$attempts = $this->attempts();
$bucket = $attempts[$key] ?? [];
return count($bucket) >= $this->maxAttempts;
}
public function availableInSeconds(string $key): int
{
$attempts = $this->attempts();
$bucket = $attempts[$key] ?? [];
if ($bucket === []) {
return 0;
}
$oldest = min($bucket);
$wait = ($oldest + $this->windowSeconds) - time();
return max(0, $wait);
}
public function hit(string $key): void
{
$attempts = $this->attempts();
$attempts[$key] ??= [];
$attempts[$key][] = time();
$attempts[$key] = $this->pruneBucket($attempts[$key]);
$this->write($attempts);
}
public function clear(string $key): void
{
$attempts = $this->attempts();
unset($attempts[$key]);
$this->write($attempts);
}
private function attempts(): array
{
$data = decode_json_file($this->path, []);
$clean = [];
foreach ($data as $key => $bucket) {
if (!is_array($bucket)) {
continue;
}
$pruned = $this->pruneBucket($bucket);
if ($pruned !== []) {
$clean[$key] = $pruned;
}
}
return $clean;
}
private function pruneBucket(array $bucket): array
{
$threshold = time() - $this->windowSeconds;
return array_values(array_filter($bucket, static fn (mixed $timestamp): bool => (int) $timestamp >= $threshold));
}
private function write(array $attempts): void
{
if (!is_dir(dirname($this->path))) {
mkdir(dirname($this->path), 0775, true);
}
file_put_contents(
$this->path,
json_encode($attempts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
final class ScoringService
{
public function normalize(array $input): array
{
return [
'date' => $input['date'] ?? today(),
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
'note' => trim((string) ($input['note'] ?? '')),
];
}
public function evaluate(array $entry, array $settings): array
{
$entry = $this->normalize($entry);
$scoring = $settings['scoring'];
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
$components = [
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'],
'energy' => $entry['energy'] * (float) $scoring['energy_multiplier'],
'stress' => (11 - $entry['stress']) * (float) $scoring['stress_multiplier'],
'sleep_hours' => $this->sleepDurationPoints((float) $entry['sleep_hours'], $scoring['sleep_duration_points']),
'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'],
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
'walk_minutes' => $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands']),
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
];
$total = round(array_sum($components), 1);
$maxTotal = round(
(10 * (float) $scoring['mood_multiplier']) +
(10 * (float) $scoring['energy_multiplier']) +
(10 * (float) $scoring['stress_multiplier']) +
max(array_map('floatval', $scoring['sleep_duration_points'])) +
(5 * (float) $scoring['sleep_feeling_multiplier']) +
$this->maxBandPoints($scoring['sport_bands']) +
$this->maxBandPoints($scoring['walk_bands']) +
(float) $scoring['journal_points'],
1
);
$label = $this->labelForScore($total, $ratings);
$guardrail = null;
foreach ($settings['guardrails'] ?? [] as $rule) {
$moodMatch = $entry['mood'] <= (int) ($rule['mood_max'] ?? 10);
$energyLimit = $rule['energy_max'] ?? null;
$energyMatch = $energyLimit === null || $entry['energy'] <= (int) $energyLimit;
if ($moodMatch && $energyMatch) {
$capped = $this->capLabel($label, (string) ($rule['cap_label'] ?? $label), $ratings);
if ($capped !== $label) {
$guardrail = (string) ($rule['cap_label'] ?? '');
$label = $capped;
}
}
}
return [
'components' => $components,
'total' => $total,
'max_total' => $maxTotal,
'label' => $label,
'guardrail' => $guardrail,
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
];
}
private function sleepDurationPoints(float $hours, array $points): float
{
if ($hours < 4) {
return (float) ($points['lt4'] ?? 0);
}
if ($hours >= 10) {
return (float) ($points['h10plus'] ?? 0);
}
$anchors = [
4.0 => (float) ($points['h4'] ?? 0),
5.0 => (float) ($points['h5'] ?? 0),
6.0 => (float) ($points['h6'] ?? 0),
7.0 => (float) ($points['h7'] ?? 0),
8.0 => (float) ($points['h8'] ?? 0),
9.0 => (float) ($points['h9'] ?? 0),
10.0 => (float) ($points['h10plus'] ?? 0),
];
$lowerHour = floor($hours);
$upperHour = ceil($hours);
if ($lowerHour === $upperHour) {
return (float) ($anchors[(float) $lowerHour] ?? 0);
}
$lowerPoints = $anchors[(float) $lowerHour] ?? 0.0;
$upperPoints = $anchors[(float) $upperHour] ?? 0.0;
$fraction = $hours - $lowerHour;
return round($lowerPoints + (($upperPoints - $lowerPoints) * $fraction), 1);
}
private function bandPoints(int $value, array $bands): float
{
foreach ($bands as $band) {
if ($value >= (int) ($band['min'] ?? 0) && $value <= (int) ($band['max'] ?? 0)) {
return (float) ($band['points'] ?? 0);
}
}
$last = end($bands);
return (float) ($last['points'] ?? 0);
}
private function maxBandPoints(array $bands): float
{
$max = 0.0;
foreach ($bands as $band) {
$max = max($max, (float) ($band['points'] ?? 0));
}
return $max;
}
private function sortedRatings(array $ratings): array
{
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
return $ratings;
}
private function labelForScore(float $score, array $ratings): string
{
foreach ($ratings as $rating) {
if ($score >= (float) $rating['min'] && $score <= (float) $rating['max']) {
return (string) $rating['label'];
}
}
if ($score < (float) ($ratings[0]['min'] ?? 0)) {
return (string) ($ratings[0]['label'] ?? 'unbewertet');
}
return (string) ($ratings[count($ratings) - 1]['label'] ?? 'unbewertet');
}
private function capLabel(string $current, string $cap, array $ratings): string
{
$order = array_map(static fn (array $rating): string => (string) $rating['label'], $ratings);
$currentIndex = array_search($current, $order, true);
$capIndex = array_search($cap, $order, true);
if ($currentIndex === false || $capIndex === false) {
return $current;
}
return $currentIndex > $capIndex ? $cap : $current;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
final class SettingsRepository
{
public function forUser(string $username): array
{
$path = $this->pathFor($username);
$saved = decode_json_file($path, []);
return array_replace_recursive(Defaults::settings(), $saved);
}
public function saveForUser(string $username, array $settings): void
{
$path = $this->pathFor($username);
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents(
$path,
json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
}
private function pathFor(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/settings.json');
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
final class UserRepository
{
private string $path;
public function __construct()
{
$this->path = storage_path('system/users.json');
}
public function hasAnyUsers(): bool
{
return count($this->all()) > 0;
}
public function all(): array
{
$data = decode_json_file($this->path, ['users' => []]);
return array_values(array_filter($data['users'] ?? [], 'is_array'));
}
public function find(string $username): ?array
{
$needle = normalize_username($username);
foreach ($this->all() as $user) {
if (($user['username'] ?? '') === $needle) {
return $user;
}
}
return null;
}
public function verify(string $username, string $password): ?array
{
$user = $this->find($username);
if ($user === null) {
return null;
}
if (!password_verify($password, (string) ($user['password_hash'] ?? ''))) {
return null;
}
return $user;
}
public function create(string $username, string $password, bool $isAdmin = false): array
{
$normalized = normalize_username($username);
if ($normalized === '' || $this->find($normalized) !== null) {
throw new RuntimeException('Benutzername existiert bereits oder ist ungueltig.');
}
$users = $this->all();
$users[] = [
'username' => $normalized,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'is_admin' => $isAdmin,
'created_at' => date(DATE_ATOM),
];
$this->write(['users' => $users]);
return $this->find($normalized) ?? [];
}
public function changePassword(string $username, string $password): void
{
$normalized = normalize_username($username);
$users = $this->all();
foreach ($users as &$user) {
if (($user['username'] ?? '') === $normalized) {
$user['password_hash'] = password_hash($password, PASSWORD_DEFAULT);
$user['updated_at'] = date(DATE_ATOM);
}
}
unset($user);
$this->write(['users' => $users]);
}
private function write(array $payload): void
{
if (!is_dir(dirname($this->path))) {
mkdir(dirname($this->path), 0775, true);
}
file_put_contents(
$this->path,
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
}
}