diff --git a/app/Console/Commands/SyncEndorsementActivities.php b/app/Console/Commands/SyncEndorsementActivities.php index 68e44330..60772eea 100644 --- a/app/Console/Commands/SyncEndorsementActivities.php +++ b/app/Console/Commands/SyncEndorsementActivities.php @@ -156,6 +156,9 @@ protected function updateEndorsementActivity(EndorsementActivity $endorsementAct } } + $eligibleSince = $this->activityService->calculateEligibleSince($endorsementData); + $endorsementActivity->eligible_since = $eligibleSince; + $endorsementActivity->save(); } catch (\Exception $e) { diff --git a/app/Http/Controllers/EndorsementController.php b/app/Http/Controllers/EndorsementController.php index 839511ee..ccb7db7b 100644 --- a/app/Http/Controllers/EndorsementController.php +++ b/app/Http/Controllers/EndorsementController.php @@ -198,6 +198,7 @@ public function mentorView(Request $request): Response 'activityHours' => $activity->activity_hours, 'status' => $activity->status, 'progress' => $activity->progress, + 'eligibleSince' => $activity->eligible_since, 'removalDate' => $activity->removal_date?->format('Y-m-d'), 'removalDays' => $activity->removal_date ? $activity->removal_date->diffInDays(now(), false) @@ -305,6 +306,7 @@ public function mentorView(Request $request): Response 'userPermissions' => [ 'canRemoveForPositions' => $canRemovePositions, 'canRemoveAny' => ($user->is_superuser || $user->is_admin) || (!empty($canRemovePositions) && count($canRemovePositions) > 0), + 'isAdmin' => $user->is_superuser || $user->is_admin, ], ]); } diff --git a/app/Models/EndorsementActivity.php b/app/Models/EndorsementActivity.php index 3ec79b10..48d3c441 100644 --- a/app/Models/EndorsementActivity.php +++ b/app/Models/EndorsementActivity.php @@ -18,6 +18,7 @@ class EndorsementActivity extends Model 'last_updated', 'last_activity_date', 'removal_date', + 'eligible_since', 'removal_notified', 'created_at_vateud', ]; @@ -27,6 +28,7 @@ class EndorsementActivity extends Model 'last_updated' => 'datetime', 'last_activity_date' => 'date', 'removal_date' => 'date', + 'eligible_since' => 'datetime', 'removal_notified' => 'boolean', 'created_at_vateud' => 'datetime', ]; diff --git a/app/Services/VatsimActivityService.php b/app/Services/VatsimActivityService.php index c8afef31..380bc6ea 100644 --- a/app/Services/VatsimActivityService.php +++ b/app/Services/VatsimActivityService.php @@ -280,4 +280,177 @@ public function getActivityProgress(float $activityMinutes): float $minRequiredMinutes = config('services.vateud.min_activity_minutes', 180); return min(($activityMinutes / $minRequiredMinutes) * 100, 100); } + + protected function getVatsimConnectionsTwoYears(int $vatsimId): array + { + $cacheKey = "vatsim_activity_2y:{$vatsimId}"; + + return Cache::remember($cacheKey, now()->addHours(6), function () use ($vatsimId) { + $start = Carbon::now()->subYears(2)->format('Y-m-d'); + $apiUrl = "http://stats.vatsim-germany.org/api/atc/{$vatsimId}/sessions/?start_date={$start}"; + + try { + $response = Http::timeout(15) + ->retry(2, 1000) + ->get($apiUrl); + + if (!$response->successful()) { + return []; + } + + $data = $response->json(); + return is_array($data) ? $data : []; + + } catch (\Exception $e) { + Log::error('Error fetching 2y VATSIM connections', [ + 'vatsim_id' => $vatsimId, + 'error' => $e->getMessage(), + ]); + return []; + } + }); + } + + protected function extractRelevantSessions(array $endorsement, array $connections): array + { + $sessions = []; + $position = $endorsement['position']; + $inTransition = Carbon::now()->lessThan(Carbon::parse($this->transitionEndDate)); + + foreach ($connections as $connection) { + $callsign = $connection['callsign'] ?? ''; + $minutes = floatval($connection['minutes_online'] ?? 0); + $date = $this->parseConnectionDate($connection); + + if (!$date || $minutes <= 0) { + continue; + } + + $matches = false; + + if (str_ends_with($position, '_CTR')) { + $ctrlPrefix = substr($position, 0, 6); + + if (str_starts_with($callsign, $ctrlPrefix) || + ($position === 'EDWW_W_CTR' && $callsign === 'EDWW_CTR')) { + $matches = true; + } + } else { + $parts = explode('_', $position); + if (count($parts) >= 2) { + $airport = $parts[0]; + $station = end($parts); + + $ctrStations = $this->ctrTopdown[$airport] ?? []; + $legacyCtrStations = $inTransition ? ($this->legacyCtrTopdown[$airport] ?? []) : []; + $appStations = $this->appTopdown[$airport] ?? []; + + $matchesSuffix = $this->suffixCondition($airport, $station, $callsign); + + $matchesCtr = false; + + $ctrAllowedStations = ['APP', 'TWR', 'GNDDEL']; + + if (in_array($station, $ctrAllowedStations, true)) { + $allCtrStations = array_unique(array_merge($ctrStations, $legacyCtrStations)); + + foreach ($allCtrStations as $ctrStation) { + if (str_starts_with($callsign, $ctrStation)) { + $matchesCtr = true; + break; + } + } + + if (!$matchesCtr && $station !== 'APP') { + foreach ($appStations as $appStation) { + if (str_starts_with($callsign, $appStation)) { + $matchesCtr = true; + break; + } + } + } + } + + if (!$matchesCtr && $station === 'APP') { + foreach ($appStations as $appStation) { + if (str_starts_with($callsign, $appStation)) { + $matchesCtr = true; + break; + } + } + } + + $matches = $matchesCtr || $matchesSuffix; + } + } + + if ($matches) { + $sessions[] = [ + 'date' => $date, + 'minutes' => $minutes, + ]; + } + } + + usort($sessions, fn($a, $b) => $a['date']->lt($b['date']) ? -1 : 1); + + return $sessions; + } + + public function calculateEligibleSince(array $endorsement): ?Carbon + { + $connections = $this->getVatsimConnectionsTwoYears($endorsement['user_cid']); + $sessions = $this->extractRelevantSessions($endorsement, $connections); + + // No sessions in 2 years -> no value + if (empty($sessions)) { + return null; + } + + $requiredMinutes = config('services.vateud.min_activity_minutes', 180); + + $events = []; + + foreach ($sessions as $session) { + $events[] = [ + 'date' => $session['date']->copy(), + 'delta' => $session['minutes'], + ]; + + $events[] = [ + 'date' => $session['date']->copy()->addDays(180), + 'delta' => -$session['minutes'], + ]; + } + + usort($events, function ($a, $b) { + return $a['date']->timestamp <=> $b['date']->timestamp; + }); + + $runningMinutes = 0; + $eligibleSince = null; + + foreach ($events as $event) { + $before = $runningMinutes; + + $runningMinutes += $event['delta']; + + if ( + $before >= $requiredMinutes && + $runningMinutes < $requiredMinutes + ) { + $eligibleSince = $event['date']->copy(); + } + + if ($runningMinutes >= $requiredMinutes) { + $eligibleSince = null; + } + } + + if ($runningMinutes < $requiredMinutes) { + return $eligibleSince; + } + + return null; + } } diff --git a/database/migrations/2026_05_13_125344_add_eligible_since_to_endorsement_activities.php b/database/migrations/2026_05_13_125344_add_eligible_since_to_endorsement_activities.php new file mode 100644 index 00000000..9f861982 --- /dev/null +++ b/database/migrations/2026_05_13_125344_add_eligible_since_to_endorsement_activities.php @@ -0,0 +1,30 @@ +timestamp('eligible_since') + ->nullable() + ->after('removal_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('endorsement_activities', function (Blueprint $table) { + $table->dropColumn('eligible_since'); + }); + } +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ff29950b..116a0b2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@emnapi/core": "1.9.1", + "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@headlessui/react": "^2.2.9", "@inertiajs/react": "^2.3.6", @@ -48,7 +48,7 @@ "next-themes": "^0.4.6", "react": "^19.2.5", "react-day-picker": "^9.13.0", - "react-dom": "^19.2.3", + "react-dom": "^19.2.5", "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -61,7 +61,7 @@ "zod": "^4.3.6" }, "devDependencies": { - "@biomejs/biome": "2.4.13", + "@biomejs/biome": "2.4.15", "@laravel/vite-plugin-wayfinder": "^0.1.7", "@types/node": "^22.19.3", "@types/ziggy-js": "^1.8.0" @@ -77,9 +77,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz", - "integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", + "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -93,20 +93,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.13", - "@biomejs/cli-darwin-x64": "2.4.13", - "@biomejs/cli-linux-arm64": "2.4.13", - "@biomejs/cli-linux-arm64-musl": "2.4.13", - "@biomejs/cli-linux-x64": "2.4.13", - "@biomejs/cli-linux-x64-musl": "2.4.13", - "@biomejs/cli-win32-arm64": "2.4.13", - "@biomejs/cli-win32-x64": "2.4.13" + "@biomejs/cli-darwin-arm64": "2.4.15", + "@biomejs/cli-darwin-x64": "2.4.15", + "@biomejs/cli-linux-arm64": "2.4.15", + "@biomejs/cli-linux-arm64-musl": "2.4.15", + "@biomejs/cli-linux-x64": "2.4.15", + "@biomejs/cli-linux-x64-musl": "2.4.15", + "@biomejs/cli-win32-arm64": "2.4.15", + "@biomejs/cli-win32-x64": "2.4.15" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz", - "integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", + "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", "cpu": [ "arm64" ], @@ -121,9 +121,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz", - "integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", + "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", "cpu": [ "x64" ], @@ -138,9 +138,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz", - "integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", + "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", "cpu": [ "arm64" ], @@ -155,9 +155,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz", - "integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", + "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", "cpu": [ "arm64" ], @@ -172,9 +172,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz", - "integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", + "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", "cpu": [ "x64" ], @@ -189,9 +189,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz", - "integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", + "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", "cpu": [ "x64" ], @@ -206,9 +206,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz", - "integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", + "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", "cpu": [ "arm64" ], @@ -223,9 +223,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz", - "integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", + "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", "cpu": [ "x64" ], @@ -246,12 +246,12 @@ "license": "MIT" }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, @@ -265,9 +265,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "dependencies": { "tslib": "^2.4.0" @@ -4613,15 +4613,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.5" } }, "node_modules/react-is": { diff --git a/package.json b/package.json index a0186738..d188f5f7 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,13 @@ "types": "tsc --noEmit" }, "devDependencies": { - "@biomejs/biome": "2.4.13", + "@biomejs/biome": "2.4.15", "@laravel/vite-plugin-wayfinder": "^0.1.7", "@types/node": "^22.19.3", "@types/ziggy-js": "^1.8.0" }, "dependencies": { - "@emnapi/core": "1.9.1", + "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@headlessui/react": "^2.2.9", "@inertiajs/react": "^2.3.6", @@ -58,7 +58,7 @@ "next-themes": "^0.4.6", "react": "^19.2.5", "react-day-picker": "^9.13.0", - "react-dom": "^19.2.3", + "react-dom": "^19.2.5", "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/resources/js/pages/endorsements/manage.tsx b/resources/js/pages/endorsements/manage.tsx index b3d89697..be725a86 100644 --- a/resources/js/pages/endorsements/manage.tsx +++ b/resources/js/pages/endorsements/manage.tsx @@ -57,6 +57,7 @@ interface EndorsementData { activityHours: number status: "active" | "warning" | "removal" progress: number + eligibleSince: string removalDate: string | null removalDays: number } @@ -74,6 +75,7 @@ interface PageProps { userPermissions: { canRemoveForPositions: string[] | null canRemoveAny: boolean + isAdmin: boolean } } @@ -122,6 +124,8 @@ export default function ManageEndorsements({ const [isProcessing, setIsProcessing] = useState(false) const [showActiveEndorsements, setShowActiveEndorsements] = useState(false) + const { isAdmin } = userPermissions + const canRemoveForPosition = (position: string): boolean => { if (userPermissions.canRemoveForPositions === null) { return true @@ -478,6 +482,7 @@ export default function ManageEndorsements({ Controller Activity Status + {isAdmin && Eligible Since} Actions @@ -564,6 +569,20 @@ export default function ManageEndorsements({ )} + {isAdmin && ( + + {state !== "active" + ? (() => { + const date = new Date( + endorsement.eligibleSince, + ) + return date.getFullYear() === 1970 + ? "Unknown" + : date.toLocaleDateString("de") + })() + : null} + + )}