Added Notifications
This commit is contained in:
@@ -7,6 +7,7 @@ use App\Models\Aircraft;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use App\Models\Notification;
|
||||
use App\Models\UserFlight;
|
||||
use App\Services\FlightStatsService;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
@@ -19,80 +20,10 @@ use Illuminate\Support\Facades\Log;
|
||||
#[Description('Command description')]
|
||||
class UpdateDepartedFlights extends Command
|
||||
{
|
||||
/**
|
||||
* Fetch live flight data from FlightStats.
|
||||
* Returns null if the request fails or no data is found.
|
||||
*/
|
||||
protected function fetchFlightData(string $airlineCode, string $flightNumber, CarbonImmutable $date): ?FlightStatData
|
||||
|
||||
public function __construct(protected FlightStatsService $flightStats)
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://www.flightstats.com/v2/api-next/flight-tracker/%s/%s/%d/%d/%d',
|
||||
$airlineCode,
|
||||
$flightNumber,
|
||||
$date->year,
|
||||
$date->month,
|
||||
$date->day,
|
||||
);
|
||||
|
||||
$response = Http::withOptions([
|
||||
'verify' => config('app.verify_ssl'),
|
||||
])->get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
Log::warning("FlightStats request failed for {$airlineCode}{$flightNumber}: HTTP {$response->status()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$flightData = $response->json('data');
|
||||
|
||||
if (empty($flightData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FlightStatData::fromApiResponse($flightData);
|
||||
}
|
||||
/**
|
||||
* Attempt to resolve the best matching Aircraft record for a given IATA equipment code.
|
||||
* Prefers passenger variants over freighters/BBJs where multiple matches exist.
|
||||
*/
|
||||
protected function guessAircraftFromIata(string $iataCode): ?Aircraft
|
||||
{
|
||||
$equipment = IataEquipmentCode::where('iata_code', $iataCode)->first();
|
||||
|
||||
if (!$equipment) {
|
||||
Log::info("Unknown IATA equipment code: {$iataCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidates = Aircraft::where('designator', $equipment->icao_code)->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
Log::info("No aircraft found for ICAO: {$equipment->icao_code} (IATA: {$iataCode})");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($candidates->count() === 1) {
|
||||
return $candidates->first();
|
||||
}
|
||||
|
||||
// Prefer passenger variants — deprioritise freighters, BBJs, and convertibles
|
||||
$deprioritised = ['freighter', 'bbj', 'combi', 'mixed', 'cargo', 'prestige', 'winglet', 'sharklet', 'freight'];
|
||||
|
||||
$pattern = implode('|', $deprioritised);
|
||||
|
||||
$passengerVariants = $candidates->filter(
|
||||
fn(Aircraft $a) => !preg_match("/({$pattern})/i", $a->display_name_short)
|
||||
);
|
||||
|
||||
if ($passengerVariants->count() === 1) {
|
||||
return $passengerVariants->first();
|
||||
}
|
||||
|
||||
if ($passengerVariants->isNotEmpty()) {
|
||||
return $passengerVariants->first();
|
||||
}
|
||||
|
||||
return $candidates->first();
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function notifyDataError(UserFlight $flight): void
|
||||
@@ -110,7 +41,7 @@ class UpdateDepartedFlights extends Command
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(): void
|
||||
{
|
||||
$now = now()->utc();
|
||||
$oneHourAgo = $now->copy()->subHours(1);
|
||||
@@ -136,7 +67,7 @@ class UpdateDepartedFlights extends Command
|
||||
|
||||
$arrivalDate = $flight->arrival_date->setTimezone($flight->arrivalAirport->timezone);
|
||||
|
||||
$data = $this->fetchFlightData($airlineCode, $flightNumber, $arrivalDate);
|
||||
$data = $this->flightStats->fetchFlightData($airlineCode, $flightNumber, $arrivalDate);
|
||||
|
||||
if (!$data) {
|
||||
$this->warn("No flight data returned for {$airlineCode}{$flightNumber}");
|
||||
@@ -170,7 +101,7 @@ class UpdateDepartedFlights extends Command
|
||||
$currentAircraft = $flight->aircraft;
|
||||
|
||||
if ($currentAircraft?->iata_code !== $data->equipment_iata) {
|
||||
$match = $this->guessAircraftFromIata($data->equipment_iata);
|
||||
$match = $this->flightStats->guessAircraftFromIata($data->equipment_iata);
|
||||
|
||||
if ($match) {
|
||||
$updates['aircraft_id'] = $match->id;
|
||||
|
||||
@@ -13,21 +13,35 @@ readonly class FlightStatData
|
||||
public ?string $equipment_iata,
|
||||
public ?string $departure_iata,
|
||||
public ?string $arrival_iata,
|
||||
public array $airline_fs_codes,
|
||||
public ?string $operating_fs,
|
||||
) {}
|
||||
|
||||
public static function fromApiResponse(array $flightData): self
|
||||
{
|
||||
$primaryFs = $flightData['resultHeader']['carrier']['fs'] ?? null;
|
||||
$operatingFs = $flightData['positional']['flexTrack']['carrierFsCode'] ?? null;
|
||||
$codeshares = array_column($flightData['codeshares'] ?? [], 'fs');
|
||||
|
||||
$fsCodes = array_values(array_unique(array_filter([
|
||||
$primaryFs,
|
||||
$operatingFs,
|
||||
...$codeshares,
|
||||
])));
|
||||
|
||||
return new self(
|
||||
aircraft_registration: $flightData['positional']['flexTrack']['tailNumber'] ?? null,
|
||||
estimated_departure_utc: isset($flightData['schedule']['estimatedActualDepartureUTC'])
|
||||
aircraft_registration: $flightData['positional']['flexTrack']['tailNumber'] ?? null,
|
||||
estimated_departure_utc: isset($flightData['schedule']['estimatedActualDepartureUTC'])
|
||||
? Carbon::parse($flightData['schedule']['estimatedActualDepartureUTC'])
|
||||
: null,
|
||||
estimated_arrival_utc: isset($flightData['schedule']['estimatedActualArrivalUTC'])
|
||||
estimated_arrival_utc: isset($flightData['schedule']['estimatedActualArrivalUTC'])
|
||||
? Carbon::parse($flightData['schedule']['estimatedActualArrivalUTC'])
|
||||
: null,
|
||||
equipment_iata: $flightData['additionalFlightInfo']['equipment']['iata'] ?? null,
|
||||
departure_iata: $flightData['departureAirport']['iata'] ?? null,
|
||||
arrival_iata: $flightData['arrivalAirport']['iata'] ?? null,
|
||||
equipment_iata: $flightData['additionalFlightInfo']['equipment']['iata'] ?? null,
|
||||
departure_iata: $flightData['departureAirport']['iata'] ?? null,
|
||||
arrival_iata: $flightData['arrivalAirport']['iata'] ?? null,
|
||||
airline_fs_codes: $fsCodes,
|
||||
operating_fs: $flightData['positional']['flexTrack']['carrierFsCode'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,17 @@ class AchievementController extends Controller
|
||||
|
||||
$userAchievements = $user->achievements()
|
||||
->with('achievement')
|
||||
->select(['achievement_id', 'progress'])
|
||||
->orderBy('achievement_id')
|
||||
->get()
|
||||
->keyBy('achievement_id');
|
||||
|
||||
$unlockedByCategory = $achievements->map(fn($group) =>
|
||||
$group->filter(fn($a) => $userAchievements->get($a->id)?->unlocked)->count()
|
||||
);
|
||||
|
||||
$unlockedCount = $userAchievements->filter(fn($ua) => $ua->unlocked)->count();
|
||||
|
||||
return Inertia::render('UserAchievements', [
|
||||
'user' => $user,
|
||||
'canEdit' => auth()->id() === $user->id,
|
||||
@@ -33,6 +40,9 @@ class AchievementController extends Controller
|
||||
'achievements' => $achievements,
|
||||
'userAchievements' => $userAchievements,
|
||||
'loggedInUser' => auth()->user(),
|
||||
'unlockedCount' => $unlockedCount,
|
||||
'unlockedByCategory' => $unlockedByCategory,
|
||||
'totalAchievements' => $achievements->flatten()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -98,6 +108,7 @@ class AchievementController extends Controller
|
||||
'airlines' => $airlines,
|
||||
'continents' => $continents,
|
||||
'aircraft_families' => $aircraftFamilies,
|
||||
'achievementCount' => $user->unlockedAchievements()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ use App\Models\Airport;
|
||||
use App\Models\CrewType;
|
||||
use App\Models\FlightClass;
|
||||
use App\Models\FlightReason;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use App\Models\SeatType;
|
||||
use App\Models\UserAction;
|
||||
use App\Models\UserFlight;
|
||||
use App\Services\FlightStatsService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -24,7 +26,24 @@ class FlightController extends Controller
|
||||
return [
|
||||
'flight_number' => ['nullable', 'string', 'max:10'],
|
||||
'departure_date' => ['required', 'date'],
|
||||
'arrival_date' => ['required', 'date'],
|
||||
'arrival_date' => ['required', 'date', function ($attribute, $value, $fail) {
|
||||
$from = request()->input('from_id');
|
||||
$to = request()->input('to_id');
|
||||
|
||||
if (!$from || !$to) return;
|
||||
|
||||
$departureAirport = Airport::find($from);
|
||||
$arrivalAirport = Airport::find($to);
|
||||
|
||||
if (!$departureAirport || !$arrivalAirport) return;
|
||||
|
||||
$departureUtc = Carbon::createFromFormat('Y-m-d\TH:i', request()->input('departure_date'), $departureAirport->timezone)->utc();
|
||||
$arrivalUtc = Carbon::createFromFormat('Y-m-d\TH:i', $value, $arrivalAirport->timezone)->utc();
|
||||
|
||||
if ($arrivalUtc->lessThanOrEqualTo($departureUtc)) {
|
||||
$fail('The arrival time must be after the departure time, accounting for time zones.');
|
||||
}
|
||||
}],
|
||||
'from_id' => ['required', 'integer', 'exists:airports,id'],
|
||||
'to_id' => ['required', 'integer', 'exists:airports,id'],
|
||||
'airline_id' => ['nullable', 'integer', 'exists:airlines,id'],
|
||||
@@ -44,24 +63,74 @@ class FlightController extends Controller
|
||||
{
|
||||
$number = strtoupper(trim($request->query('number', '')));
|
||||
|
||||
// Extract the airline code prefix — letters at the start e.g. "QF" from "QF1"
|
||||
preg_match('/^([A-Z]{2,3})/', $number, $matches);
|
||||
$code = $matches[1] ?? null;
|
||||
$isIata = strlen($code) === 2;
|
||||
$codeColumn = $isIata ? 'IATA_code' : 'ICAO_code';
|
||||
preg_match('/^([A-Z]{2,3})(\d+)/', $number, $matches);
|
||||
$code = $matches[1] ?? null;
|
||||
$flightNumber = $matches[2] ?? null;
|
||||
$isIata = strlen($code) === 2;
|
||||
$codeColumn = $isIata ? 'IATA_code' : 'ICAO_code';
|
||||
|
||||
$apiAirlineCodes = [];
|
||||
$fromOptions = [];
|
||||
$toOptions = [];
|
||||
$aircraftOptions = [];
|
||||
|
||||
if (strlen($number) >= 3 && $isIata) {
|
||||
$flightStatsApi = new FlightStatsService();
|
||||
$flightData = $flightStatsApi->fetchFlightData($code, $flightNumber);
|
||||
|
||||
if ($flightData) {
|
||||
$apiAirlineCodes = $flightData->airline_fs_codes;
|
||||
|
||||
$fromOptions = Airport::where('iata_code', $flightData->departure_iata)
|
||||
->get()
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name, 'country_code' => strtolower($a->region->country->code)])
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$toOptions = Airport::where('iata_code', $flightData->arrival_iata)
|
||||
->get()
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name, 'country_code' => strtolower($a->region->country->code)])
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if($flightData->equipment_iata){
|
||||
$equipment = IataEquipmentCode::where('iata_code', $flightData->equipment_iata)->first();
|
||||
if ($equipment) {
|
||||
$bestGuess = $flightStatsApi->guessAircraftFromIata($flightData->equipment_iata);
|
||||
|
||||
$aircraftOptions = Aircraft::where('designator', $equipment->icao_code)
|
||||
->get()
|
||||
->sortBy(fn($a) => $a->id === $bestGuess?->id ? 0 : 1)
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name])
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Airlines from the typed code + any additional codes from the API, merged and deduped by id
|
||||
$allCodes = array_unique(array_filter([$code, ...$apiAirlineCodes]));
|
||||
|
||||
$airlines = Airline::where(function ($q) use ($codeColumn, $code, $allCodes) {
|
||||
$q->whereIn($codeColumn, $allCodes);
|
||||
})
|
||||
->get()
|
||||
->unique('id')
|
||||
->sortBy(function ($a) use ($code, $flightData) {
|
||||
if ($a->IATA_code === $flightData?->operating_fs) return 0;
|
||||
if ($a->IATA_code === $code) return 1;
|
||||
return 2;
|
||||
})
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name, 'logo_url' => $a->logo_url])
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$airlines = $code
|
||||
? Airline::where($codeColumn, $code)
|
||||
->get()
|
||||
->map(fn ($airline) => ['value' => $airline->id, 'title' => $airline->display_name, 'logo_url' => $airline->logo_url])
|
||||
->values()
|
||||
->toArray()
|
||||
: collect()->toArray();
|
||||
return response()->json([
|
||||
'airline_options' => $airlines,
|
||||
'from_options' => [],
|
||||
'to_options' => [],
|
||||
'aircraft_options' => [],
|
||||
'from_options' => $fromOptions,
|
||||
'to_options' => $toOptions,
|
||||
'aircraft_options' => $aircraftOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,37 +35,21 @@ class FlightImportController extends Controller
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getPossibleAircraft(string $aircraftQuery) {
|
||||
public function getPossibleAircraft(string $aircraftQuery): array
|
||||
{
|
||||
preg_match('/\((\w+)\)/', $aircraftQuery, $matches);
|
||||
$designator = $matches[1] ?? null;
|
||||
|
||||
$sortOverrides = [
|
||||
'B788' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END",
|
||||
'B789' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END",
|
||||
];
|
||||
if (!$designator) return [];
|
||||
|
||||
if(!$designator){
|
||||
$aircraft = [];
|
||||
} else {
|
||||
|
||||
$aircraft = Aircraft::when($designator, fn($query) => $query->where('designator', 'ilike', $designator))
|
||||
->when(
|
||||
isset($sortOverrides[$designator]),
|
||||
fn($q) => $q->orderByRaw($sortOverrides[$designator])
|
||||
)
|
||||
->orderBy('model_full_name')
|
||||
->limit(10)
|
||||
->get(['id', 'manufacturer_code', 'model_full_name', 'designator'])
|
||||
->map(fn($aircraft) => [
|
||||
'value' => $aircraft->id,
|
||||
'title' => $aircraft->display_name,
|
||||
])
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
}
|
||||
|
||||
return $aircraft;
|
||||
return Aircraft::where('designator', 'ilike', $designator)
|
||||
->orderByDesc('preferred')
|
||||
->orderBy('model_full_name')
|
||||
->limit(10)
|
||||
->get(['id', 'manufacturer_code', 'model_full_name', 'designator', 'preferred'])
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name])
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getPossibleAirports(string $airportQuery) {
|
||||
|
||||
@@ -15,10 +15,12 @@ class Aircraft extends Model
|
||||
'engine_type',
|
||||
'engine_count',
|
||||
'wtc',
|
||||
'preferred'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'engine_count' => 'integer',
|
||||
'preferred' => 'boolean'
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
@@ -51,6 +53,7 @@ class Aircraft extends Model
|
||||
'A380' => ['A380', 'A388'],
|
||||
];
|
||||
|
||||
|
||||
protected function displayName() : Attribute{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
|
||||
@@ -41,6 +41,26 @@ class User extends Authenticatable
|
||||
return $this->hasMany(UserAchievement::class);
|
||||
}
|
||||
|
||||
public function unlockedAchievements(): HasMany
|
||||
{
|
||||
return $this->achievements()
|
||||
->join('achievements', 'achievements.id', '=', 'user_achievements.achievement_id')
|
||||
->where(function ($query) {
|
||||
$query
|
||||
// Non-progressive achievements: always count
|
||||
->where(function ($q) {
|
||||
$q->where('achievements.progressive', false)
|
||||
->orWhereNull('achievements.progressive');
|
||||
})
|
||||
// Progressive achievements: only if progress >= threshold
|
||||
->orWhere(function ($q) {
|
||||
$q->where('achievements.progressive', true)
|
||||
->whereNotNull('achievements.threshold')
|
||||
->whereColumn('user_achievements.progress', '>=', 'achievements.threshold');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function resolveRouteBinding($value, $field = null): ?User
|
||||
{
|
||||
return $this->where('name', 'ilike', $value)->firstOrFail();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@@ -26,6 +27,21 @@ class UserAchievement extends Model
|
||||
'progress' => 'integer',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'unlocked',
|
||||
];
|
||||
|
||||
protected function unlocked(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->achievement) return false;
|
||||
if (!$this->achievement->progressive || !$this->achievement->threshold) return true;
|
||||
return ($this->progress ?? 0) >= $this->achievement->threshold;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Relationships
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -74,6 +74,7 @@ class GeneralFlyingChecker extends BaseChecker
|
||||
|
||||
$this->awardProgress((int) $totalDistance, 'general_flying.circumference_of_the_earth');
|
||||
$this->awardProgress((int) $totalDistance, 'general_flying.to_the_moon');
|
||||
$this->awardProgress((int) $totalDistance, 'general_flying.gigametre');
|
||||
|
||||
$this->awardProgress($count,'general_flying.10_flights');
|
||||
$this->awardProgress($count,'general_flying.50_flights');
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\DTOs\FlightStatData;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FlightStatsService
|
||||
{
|
||||
|
||||
public function fetchOtherDays(string $airlineCode, string $flightNumber): array
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://www.flightstats.com/v2/api-next/flight-tracker/other-days/%s/%s',
|
||||
$airlineCode,
|
||||
$flightNumber,
|
||||
);
|
||||
|
||||
$response = Http::withOptions([
|
||||
'verify' => config('app.verify_ssl'),
|
||||
])->get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
if (!is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function fetchFlightData(string $airlineCode, string $flightNumber, ?CarbonImmutable $date = null): ?FlightStatData
|
||||
{
|
||||
$specificDate = $date !== null;
|
||||
$date ??= now()->utc()->toImmutable();
|
||||
|
||||
$data = $this->fetchForDate($airlineCode, $flightNumber, $date);
|
||||
|
||||
if ($data || $specificDate) return $data;
|
||||
|
||||
$otherDays = $this->fetchOtherDays($airlineCode, $flightNumber);
|
||||
|
||||
$pastDays = collect($otherDays)
|
||||
->filter(fn($day) => !empty($day['flights']))
|
||||
->sortByDesc(fn($day) => $day['year'] . $day['date1']);
|
||||
|
||||
foreach ($pastDays as $day) {
|
||||
$pastDate = CarbonImmutable::createFromFormat('Y-d-M', $day['year'] . '-' . $day['date1']);
|
||||
$result = $this->fetchForDate($airlineCode, $flightNumber, $pastDate);
|
||||
if ($result) return $result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function fetchForDate(string $airlineCode, string $flightNumber, CarbonImmutable $date): ?FlightStatData
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://www.flightstats.com/v2/api-next/flight-tracker/%s/%s/%d/%d/%d',
|
||||
$airlineCode,
|
||||
$flightNumber,
|
||||
$date->year,
|
||||
$date->month,
|
||||
$date->day,
|
||||
);
|
||||
|
||||
$response = Http::withOptions([
|
||||
'verify' => config('app.verify_ssl'),
|
||||
])->get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
Log::warning("FlightStats request failed for {$airlineCode}{$flightNumber}: HTTP {$response->status()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$flightData = $response->json('data');
|
||||
|
||||
if (empty($flightData)) return null;
|
||||
|
||||
return FlightStatData::fromApiResponse($flightData);
|
||||
}
|
||||
|
||||
public function guessAircraftFromIata(string $iataCode): ?Aircraft
|
||||
{
|
||||
$equipment = IataEquipmentCode::where('iata_code', $iataCode)->first();
|
||||
|
||||
if (!$equipment) {
|
||||
Log::info("Unknown IATA equipment code: {$iataCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$aircraft = Aircraft::where('designator', $equipment->icao_code)
|
||||
->orderByDesc('preferred')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (!$aircraft) {
|
||||
Log::info("No aircraft found for ICAO: {$equipment->icao_code} (IATA: {$iataCode})");
|
||||
}
|
||||
|
||||
return $aircraft;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\AchievementCategory;
|
||||
use App\Models\AchievementDifficulty;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
|
||||
function setDesignatorDefault($designator, $model_name, $manufacturer_code=null): self
|
||||
{
|
||||
$manufacturerSearchTerm = $manufacturer_code ? ['manufacturer_code' => $manufacturer_code] : [];
|
||||
|
||||
$count = Aircraft::where([
|
||||
'designator' => $designator,
|
||||
'model_full_name' => $model_name,
|
||||
...$manufacturerSearchTerm,
|
||||
])->update(['preferred' => true]);
|
||||
|
||||
echo $designator . ' ' . $model_name . ' ' . $manufacturer_code . ' ' . $count . PHP_EOL;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
function setDesignatorDefaults(): void
|
||||
{
|
||||
$this
|
||||
->setDesignatorDefault('A19N', 'A-319neo')
|
||||
->setDesignatorDefault('A306', 'A-300B4-600')
|
||||
->setDesignatorDefault('A30B', 'A-300B4-200')
|
||||
->setDesignatorDefault('A310', 'A-310')
|
||||
->setDesignatorDefault('A318', 'A-318')
|
||||
->setDesignatorDefault('A319', 'A-319')
|
||||
->setDesignatorDefault('A320', 'A-320')
|
||||
->setDesignatorDefault('A332', 'A-330-200')
|
||||
->setDesignatorDefault('A338', 'A-330-800')
|
||||
->setDesignatorDefault('A339', 'A-330-900')
|
||||
->setDesignatorDefault('A342', 'A-340-200')
|
||||
->setDesignatorDefault('A343', 'A-340-300')
|
||||
->setDesignatorDefault('A345', 'A-340-500')
|
||||
->setDesignatorDefault('A346', 'A-340-600')
|
||||
->setDesignatorDefault('A359', 'A-350-900 XWB')
|
||||
->setDesignatorDefault('A35K', 'A-350-1000 XWB')
|
||||
->setDesignatorDefault('A388', 'A-380-800')
|
||||
->setDesignatorDefault('AN24', 'An-24')
|
||||
->setDesignatorDefault('AN26', 'An-26')
|
||||
->setDesignatorDefault('AT43', 'ATR-42-300')
|
||||
->setDesignatorDefault('AT44', 'ATR-42-400')
|
||||
->setDesignatorDefault('AT72', 'ATR-72-201')
|
||||
->setDesignatorDefault('AT73', 'ATR-72-211')
|
||||
->setDesignatorDefault('AT75', 'ATR-72-500')
|
||||
->setDesignatorDefault('AT76', 'ATR-72-600')
|
||||
->setDesignatorDefault('B37M', '737 MAX 7')
|
||||
->setDesignatorDefault('B38M', '737 MAX 8')
|
||||
->setDesignatorDefault('B39M', '737 MAX 9')
|
||||
->setDesignatorDefault('B3XM', '737 MAX 10')
|
||||
->setDesignatorDefault('B461', 'BAe-146-100')
|
||||
->setDesignatorDefault('B462', 'BAe-146-200')
|
||||
->setDesignatorDefault('B703', '707-300')
|
||||
->setDesignatorDefault('B712', '717-200')
|
||||
->setDesignatorDefault('B721', '727-100')
|
||||
->setDesignatorDefault('B732', '737-200')
|
||||
->setDesignatorDefault('B737', '737-700')
|
||||
->setDesignatorDefault('B738', '737-800')
|
||||
->setDesignatorDefault('B739', '737-900')
|
||||
->setDesignatorDefault('B742', '747-200')
|
||||
->setDesignatorDefault('B748', '747-8')
|
||||
->setDesignatorDefault('B752', '757-200')
|
||||
->setDesignatorDefault('B772', '777-200')
|
||||
->setDesignatorDefault('B778', '777-8')
|
||||
->setDesignatorDefault('B77L', '777-200LR')
|
||||
->setDesignatorDefault('B77W', '777-300ER')
|
||||
->setDesignatorDefault('B788', '787-8 Dreamliner')
|
||||
->setDesignatorDefault('B789', '787-9 Dreamliner')
|
||||
->setDesignatorDefault('BCS1', 'A-220-100')
|
||||
->setDesignatorDefault('BCS3', 'A-220-300')
|
||||
->setDesignatorDefault('CRJ1', 'CL-600 Regional Jet CRJ-100')
|
||||
->setDesignatorDefault('CRJ2', 'CL-600 Regional Jet CRJ-200')
|
||||
->setDesignatorDefault('CRJ7', 'CL-600 Regional Jet CRJ-700')
|
||||
->setDesignatorDefault('CRJ9', 'CL-600 Regional Jet CRJ-900')
|
||||
->setDesignatorDefault('DC10', 'DC-10')
|
||||
->setDesignatorDefault('DC3', 'DC-3')
|
||||
->setDesignatorDefault('DH8B', 'DHC-8-200 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DH8A', 'DHC-8-100 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DH8C', 'DHC-8-300 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DH8D', 'DHC-8-400 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DHC6', 'DHC-6 Twin Otter','DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('E120', 'EMB-120 Brasilia', 'EMBRAER')
|
||||
->setDesignatorDefault('E135', 'ERJ-135')
|
||||
->setDesignatorDefault('E145', 'ERJ-145ER')
|
||||
->setDesignatorDefault('E170', '170')
|
||||
->setDesignatorDefault('E190', '190')
|
||||
->setDesignatorDefault('E195', '195')
|
||||
->setDesignatorDefault('E275', 'E175-E2')
|
||||
->setDesignatorDefault('E290', 'E190-E2')
|
||||
->setDesignatorDefault('E295', 'E195-E2')
|
||||
->setDesignatorDefault('F27', 'F-27 Friendship', 'FOKKER')
|
||||
->setDesignatorDefault('JS32', 'BAe-3200 Jetstream Super 31', 'BRITISH AEROSPACE')
|
||||
->setDesignatorDefault('JS41', 'BAe-4100 Jetstream 41', 'BRITISH AEROSPACE')
|
||||
->setDesignatorDefault('MD81', 'MD-81', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD82', 'MD-82', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD83', 'MD-83', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD87', 'MD-87', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD88', 'MD-88', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD90', 'MD-90', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('AJ27', 'C-909')
|
||||
;
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('aircraft', function (Blueprint $table) {
|
||||
$table->boolean('preferred')->default(false)->after('designator');
|
||||
});
|
||||
|
||||
$this->setDesignatorDefaults();
|
||||
|
||||
IataEquipmentCode::create([
|
||||
'iata_code' => '388',
|
||||
'icao_code' => 'A388',
|
||||
'description' => 'Airbus A380'
|
||||
]);
|
||||
|
||||
Achievement::create([
|
||||
'name' => 'Gone a Gigametre',
|
||||
'internal_name' => 'general_flying.gigametre',
|
||||
'short_description' => 'Fly 1 million kilometres.',
|
||||
'long_description' => '',
|
||||
'achievement_difficulty_id' => AchievementDifficulty::whereInternalName('near_impossible')->first()->id,
|
||||
'achievement_category_id' => AchievementCategory::whereInternalName('general_flying')->first()->id,
|
||||
'icon' => 'standard_achievement.png',
|
||||
'has_page' => false,
|
||||
'sort_order' => 18,
|
||||
'progressive' => true,
|
||||
'threshold' => 1000000,
|
||||
]);
|
||||
|
||||
Achievement::whereInternalName('aircraft.smaller_manufacturer')->update([
|
||||
'difficulty_description' => 'General Aviation flights do not count. Only flights with an airline and flight number can earn this achievement',
|
||||
]);
|
||||
|
||||
foreach(User::all() as $user) {
|
||||
$user->calculateAchievements();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import Distance from "@/Components/Distance.vue";
|
||||
const distanceAchievements = [
|
||||
'general_flying.circumference_of_the_earth',
|
||||
'general_flying.to_the_moon',
|
||||
'general_flying.gigametre'
|
||||
];
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -29,6 +30,10 @@ const progress = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const inProgress = computed(() =>
|
||||
!unlocked.value && (props.userAchievement?.progress ?? 0) > 0
|
||||
)
|
||||
|
||||
const unlocked = computed(() => {
|
||||
if (!props.userAchievement) return false
|
||||
if (props.achievement.progressive) return (progress.value?.percentage ?? 0) >= 100
|
||||
@@ -52,11 +57,11 @@ const difficultyVariant = computed(() => {
|
||||
<template>
|
||||
<v-card
|
||||
class="achievement-card"
|
||||
:class="{ locked: !unlocked }"
|
||||
:class="{ locked: !unlocked && !inProgress }"
|
||||
rounded="lg"
|
||||
elevation="2"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-card-text class="cardLayout">
|
||||
<div class="achievement-inner">
|
||||
<div class="achievement-icon-wrap" :class="{ unlocked }">
|
||||
<v-icon
|
||||
@@ -110,7 +115,7 @@ const difficultyVariant = computed(() => {
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<br/>
|
||||
<div class="button-spacer" />
|
||||
<ButtonLink
|
||||
variant="outlined"
|
||||
label="View Details"
|
||||
@@ -122,15 +127,27 @@ const difficultyVariant = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cardLayout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.achievement-card {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.button-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.achievement-card.locked .achievement-inner {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
|
||||
.achievement-card:hover .achievement-inner,
|
||||
.achievement-inner {
|
||||
display: flex;
|
||||
|
||||
@@ -33,7 +33,6 @@ defineProps<{
|
||||
</div>
|
||||
|
||||
<div class="pass-centre">
|
||||
<div class="pass-plane-icon">✈</div>
|
||||
<AirlineLogo :airline="flight.airline" size="44" class="pass-logo" />
|
||||
<div class="pass-flight-number">{{ flight.flight_number }}</div>
|
||||
<div class="pass-airline-name">{{ flight.airline?.name }}</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function directedKey(dep: string, arr: string): string {
|
||||
}
|
||||
|
||||
export function labelFor(a: string, b: string): string {
|
||||
return `${a} ↔ ${b}`
|
||||
return `${a}↔${b}`
|
||||
}
|
||||
|
||||
export function continentNameOf(flight: Flight, side: 'departure' | 'arrival'): string | null {
|
||||
|
||||
@@ -86,7 +86,7 @@ async function lookupFlight() {
|
||||
}
|
||||
if (data.aircraft_options?.length) {
|
||||
aircraftOptionsData.value = data.aircraft_options
|
||||
if (data.aircraft_options.length === 1 && !form.aircraft) form.aircraft = data.aircraft_options[0]
|
||||
if (!form.aircraft) form.aircraft = data.aircraft_options[0]
|
||||
}
|
||||
|
||||
lookupKey.value++
|
||||
@@ -143,6 +143,18 @@ const submitForm = useForm({
|
||||
auto_update: false,
|
||||
})
|
||||
|
||||
|
||||
const departureIsFuture = computed(() => {
|
||||
if (!form.departure_date) return false
|
||||
return new Date(form.departure_date) > new Date()
|
||||
})
|
||||
|
||||
|
||||
watch(departureIsFuture, (isFuture) => {
|
||||
form.auto_update = isFuture
|
||||
}, { immediate: true })
|
||||
|
||||
|
||||
function submit() {
|
||||
submitForm.flight_number = flightNumber.value
|
||||
submitForm.departure_date = form.departure_date
|
||||
@@ -411,11 +423,11 @@ const arrivalMax = computed(() => {
|
||||
</v-row>
|
||||
|
||||
<!-- ── Auto update ────────────────────────────────────────── -->
|
||||
<v-row>
|
||||
<v-row v-if="departureIsFuture">
|
||||
<v-col cols="12">
|
||||
<v-checkbox
|
||||
v-model="form.auto_update"
|
||||
label="Automatically update aircraft details within 24 hours of flight departure."
|
||||
label="Automatically update flight details within 24 hours of flight departure"
|
||||
:disabled="!lookupComplete"
|
||||
hide-details
|
||||
density="compact"
|
||||
|
||||
@@ -27,6 +27,7 @@ const props = defineProps<{
|
||||
airlines: Airline[]
|
||||
continents: Continent[]
|
||||
aircraft_families: Record<string, string[]>
|
||||
achievementCount: number
|
||||
}>()
|
||||
|
||||
|
||||
@@ -66,7 +67,7 @@ const difficultyVariant = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProfileLayout :user="user" :isFollowing="isFollowing" :loading="flightsLoading">
|
||||
<ProfileLayout :achievementCount="achievementCount" :user="user" :isFollowing="isFollowing" :loading="flightsLoading">
|
||||
<Head :title="`${achievement.name}`" />
|
||||
<div class="innerLayout">
|
||||
<ButtonLink variant="flat" icon="mdi-arrow-left" :label="`Back to ${user.name}'s Achievements`" :href="route('profile.achievements', { user: user.name })" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import {computed} from "vue"
|
||||
import { ref, computed } from "vue"
|
||||
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue"
|
||||
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue"
|
||||
import AchievementCard from "@/Components/FlightsGoneBy/AchievementCard.vue"
|
||||
import {Achievement, User, UserAchievement} from "@/Types/types"
|
||||
import { Head } from "@inertiajs/vue3";
|
||||
import MainLayout from "@/Layouts/MainLayout.vue";
|
||||
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
|
||||
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
|
||||
|
||||
defineOptions({ layout: MainLayout })
|
||||
|
||||
@@ -15,21 +17,39 @@ const props = defineProps<{
|
||||
isFollowing: boolean
|
||||
achievements: Record<string, Achievement[]>
|
||||
userAchievements: Record<number, UserAchievement>
|
||||
unlockedCount: number
|
||||
totalAchievements: number
|
||||
unlockedByCategory: Record<string, number>
|
||||
}>()
|
||||
|
||||
const totalAchievements = computed(() =>
|
||||
Object.values(props.achievements).reduce((sum, group) => sum + group.length, 0)
|
||||
const hideImpossible = ref(false)
|
||||
|
||||
const filteredAchievements = computed(() => {
|
||||
if (!hideImpossible.value) return props.achievements
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(props.achievements).map(([category, achievements]) => [
|
||||
category,
|
||||
achievements.filter(a => a.difficulty?.internal_name !== 'impossible')
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
const filteredUnlockedByCategory = computed(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(filteredAchievements.value).map(([category, achievements]) => [
|
||||
category,
|
||||
achievements.filter(a => props.userAchievements[a.id]?.unlocked).length
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
const unlockedCount = computed(() =>
|
||||
Object.values(props.achievements)
|
||||
.flat()
|
||||
.filter(a => {
|
||||
const ua = props.userAchievements[a.id]
|
||||
if (!ua) return false
|
||||
if (!a.progressive || !a.threshold) return true
|
||||
return (ua.progress ?? 0) >= a.threshold
|
||||
}).length
|
||||
const filteredTotal = computed(() =>
|
||||
Object.values(filteredAchievements.value).reduce((sum, group) => sum + group.length, 0)
|
||||
)
|
||||
|
||||
const filteredUnlockedCount = computed(() =>
|
||||
Object.values(filteredUnlockedByCategory.value).reduce((sum, count) => sum + count, 0)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -45,11 +65,20 @@ const unlockedCount = computed(() =>
|
||||
|
||||
<div class="achievements-page">
|
||||
<div class="achievements-summary">
|
||||
<span class="summary-text">
|
||||
{{ unlockedCount }} / {{ totalAchievements }} achievements unlocked
|
||||
</span>
|
||||
<div class="summary-controls">
|
||||
<span class="summary-text">
|
||||
{{ filteredUnlockedCount }} / {{ filteredTotal }} achievements unlocked
|
||||
</span>
|
||||
<v-checkbox
|
||||
v-model="hideImpossible"
|
||||
label="Hide Impossible Achievements"
|
||||
density="compact"
|
||||
hide-details
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="Math.round((unlockedCount / totalAchievements) * 100)"
|
||||
:model-value="Math.round((filteredUnlockedCount / filteredTotal) * 100)"
|
||||
color="amber"
|
||||
rounded
|
||||
height="6"
|
||||
@@ -57,18 +86,17 @@ const unlockedCount = computed(() =>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(categoryAchievements, categoryName) in achievements"
|
||||
<Panel
|
||||
v-for="(categoryAchievements, categoryName) in filteredAchievements"
|
||||
:key="categoryName"
|
||||
class="achievement-category"
|
||||
>
|
||||
<div class="category-header">
|
||||
<h2 class="category-name">{{ categoryName }}</h2>
|
||||
<PanelHeader class="category-header">{{ categoryName }}
|
||||
<span class="category-count">
|
||||
{{ categoryAchievements.filter(a => userAchievements[a.id]).length }}
|
||||
{{ filteredUnlockedByCategory[categoryName] ?? 0 }}
|
||||
/ {{ categoryAchievements.length }}
|
||||
</span>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
<div class="achievement-grid">
|
||||
<AchievementCard
|
||||
@@ -79,7 +107,7 @@ const unlockedCount = computed(() =>
|
||||
:user-achievement="userAchievements[achievement.id]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</ProfileLayout>
|
||||
</template>
|
||||
@@ -101,6 +129,12 @@ const unlockedCount = computed(() =>
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
@@ -120,14 +154,6 @@ const unlockedCount = computed(() =>
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.5;
|
||||
|
||||
Vendored
+1
@@ -106,6 +106,7 @@ export interface UserAchievement {
|
||||
id: number
|
||||
user_id: number
|
||||
achievement_id: number
|
||||
unlocked: boolean
|
||||
progress: number | null
|
||||
achievement?: Achievement
|
||||
created_at: string | null
|
||||
|
||||
Reference in New Issue
Block a user