diff --git a/app/Http/Controllers/AchievementController.php b/app/Http/Controllers/AchievementController.php
index be5c780..7671809 100644
--- a/app/Http/Controllers/AchievementController.php
+++ b/app/Http/Controllers/AchievementController.php
@@ -3,6 +3,9 @@
namespace App\Http\Controllers;
use App\Models\Achievement;
+use App\Models\Aircraft;
+use App\Models\Alliance;
+use App\Models\Continent;
use App\Models\Country;
use App\Models\User;
use Illuminate\Http\Request;
@@ -15,7 +18,7 @@ class AchievementController extends Controller
$achievements = Achievement::with(['category', 'difficulty'])
->get()
->groupBy(fn(Achievement $a) => $a->category->name)
- ->map(fn($group) => $group->sortBy('id')->values());
+ ->map(fn($group) => $group->sortBy('sort_order')->values());
$userAchievements = $user->achievements()
->with('achievement')
@@ -29,26 +32,72 @@ class AchievementController extends Controller
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
'achievements' => $achievements,
'userAchievements' => $userAchievements,
+ 'loggedInUser' => auth()->user(),
]);
}
+ function getRegionsByCountryCode(string $countryCode)
+ {
+ return Country::whereCode($countryCode)
+ ->first()
+ ->regions()
+ ->orderBy('name')
+ ->get()
+ ->toArray();
+ }
+
public function specific(User $user, Achievement $achievement)
{
$regions = match($achievement->internal_name){
- 'fun_challenges.australian_states' => Country::where('code', 'AU')->first()->regions->toArray(),
- 'fun_challenges.chinese_provinces' => Country::where('code', 'CN')->first()->regions->toArray(),
- 'fun_challenges.canadian_provinces' => Country::where('code', 'CA')->first()->regions->toArray(),
- 'fun_challenges.us_states' => Country::where('code', 'US')->first()->regions->toArray(),
+ 'fun_challenges.australian_states' => $this->getRegionsByCountryCode('AU'),
+ 'fun_challenges.chinese_provinces' => $this->getRegionsByCountryCode('CN'),
+ 'fun_challenges.canadian_provinces' => $this->getRegionsByCountryCode('CA'),
+ 'fun_challenges.brazilian_states' => $this->getRegionsByCountryCode('BR'),
+ 'fun_challenges.us_states' => $this->getRegionsByCountryCode('US'),
+ default => [],
+ };
+
+ $allianceInternalName = match($achievement->internal_name){
+ 'airlines_alliances.all_star_alliance' => 'star_alliance',
+ 'airlines_alliances.all_oneworld' => 'oneworld',
+ 'airlines_alliances.all_skyteam' => 'skyteam',
+ 'airlines_alliances.all_vanilla_alliance' => 'vanilla_alliance',
+ default => null,
+ };
+
+ $continents = match($achievement->internal_name){
+ 'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(),
+ default => [],
+ };
+
+ $alliance = null;
+ $airlines = [];
+
+ if ($allianceInternalName) {
+ $alliance = Alliance::where('internal_name', $allianceInternalName)
+ ->with('airlines')
+ ->firstOrFail();
+ $airlines = $alliance->airlines()->with('country')->orderBy('name')->get();
+ }
+
+ $aircraftFamilies = match($achievement->internal_name){
+ 'aircraft.all_boeing_7x7' => Aircraft::BOEING_FAMILIES,
+ 'aircraft.all_airbus_a3xx' => Aircraft::AIRBUS_FAMILIES,
default => [],
};
return Inertia::render('Profile/UserAchievement', [
- 'user' => $user,
- 'achievement' => $achievement,
+ 'user' => $user,
+ 'achievement' => $achievement,
+ 'loggedInUser' => auth()->user(),
'userAchievement' => $user->achievements()->where('achievement_id', $achievement->id)->first(),
- 'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
- 'flight_api_url' => FlightProfileController::getUserFlightApiURL($user),
- 'regions' => $regions,
+ 'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
+ 'flight_api_url' => FlightProfileController::getUserFlightApiURL($user),
+ 'regions' => $regions,
+ 'alliance' => $alliance,
+ 'airlines' => $airlines,
+ 'continents' => $continents,
+ 'aircraft_families' => $aircraftFamilies,
]);
}
diff --git a/app/Http/Controllers/Api/UserApiController.php b/app/Http/Controllers/Api/UserApiController.php
index 28ecfda..16b381d 100644
--- a/app/Http/Controllers/Api/UserApiController.php
+++ b/app/Http/Controllers/Api/UserApiController.php
@@ -7,6 +7,7 @@ use App\Models\User;
use App\Models\UserFlight;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
class UserApiController extends ApiController
{
@@ -47,7 +48,7 @@ class UserApiController extends ApiController
]);
}
- public function flights(string $username): JsonResponse
+ public function flights(string $username, Request $request): JsonResponse
{
$user = User::where('name', 'ilike', $username)->first();
@@ -55,6 +56,6 @@ class UserApiController extends ApiController
return response()->json(['message' => 'User not found'], 404);
}
- return response()->json($user->FlightController()->flights());
+ return response()->json($user->FlightController()->flights($request));
}
}
diff --git a/app/Http/Controllers/FlightProfileController.php b/app/Http/Controllers/FlightProfileController.php
index 8bab328..8579adf 100644
--- a/app/Http/Controllers/FlightProfileController.php
+++ b/app/Http/Controllers/FlightProfileController.php
@@ -22,6 +22,7 @@ class FlightProfileController extends Controller
'selectedFlightId' => $selectedFlightId,
'flight_api_url' => self::getUserFlightApiURL($user),
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
+ 'flightCount' => $user->departedFlights()->count(),
];
}
diff --git a/app/Http/Controllers/UserFlightController.php b/app/Http/Controllers/UserFlightController.php
index 78c9c53..70f96dc 100644
--- a/app/Http/Controllers/UserFlightController.php
+++ b/app/Http/Controllers/UserFlightController.php
@@ -15,7 +15,8 @@ class UserFlightController extends Controller
$this->user = $user;
}
- public function flights(){
+ public function flights(?Request $request = null)
+ {
return UserFlight::where('user_id', $this->user->id)
->with([
'departureAirport.region.country',
@@ -30,6 +31,7 @@ class UserFlightController extends Controller
'flightClass',
'crewType'
])
+ ->when($request?->boolean('departed_only'), fn($q) => $q->where('departure_date', '<=', now('UTC')))
->orderBy('departure_date', 'desc')
->get();
}
diff --git a/app/Models/Achievement.php b/app/Models/Achievement.php
index ca13693..3233b1f 100644
--- a/app/Models/Achievement.php
+++ b/app/Models/Achievement.php
@@ -40,6 +40,7 @@ class Achievement extends Model
'achievement_difficulty_id',
'threshold',
'has_page',
+ 'sort_order',
];
protected $casts = [
diff --git a/app/Models/Aircraft.php b/app/Models/Aircraft.php
index 4974837..a314892 100644
--- a/app/Models/Aircraft.php
+++ b/app/Models/Aircraft.php
@@ -26,9 +26,29 @@ class Aircraft extends Model
'display_name_short'
];
- const array IATA_ALIAS_MAP = [
- '7S8' => '73H',
- '7S9' => '73J'
+ public const array BOEING_FAMILIES = [
+ '707' => ['B701', 'B703', 'B720'],
+ '717' => ['B712', 'B717'],
+ '727' => ['B721', 'B722', 'B727'],
+ '737' => ['B731', 'B732', 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', 'B37M', 'B38M', 'B39M'],
+ '747' => ['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S'],
+ '757' => ['B752', 'B753', 'B757'],
+ '767' => ['B762', 'B763', 'B764', 'B767'],
+ '777' => ['B772', 'B773', 'B77L', 'B77W', 'B778', 'B779'],
+ '787' => ['B788', 'B789', 'B78X'],
+ ];
+
+ public const array AIRBUS_FAMILIES = [
+ 'A300' => ['A30B', 'A300', 'A306'],
+ 'A310' => ['A310', 'A312', 'A313'],
+ 'A318' => ['A318'],
+ 'A319' => ['A319', 'A31X'],
+ 'A320' => ['A320', 'A20N'],
+ 'A321' => ['A321', 'A21N'],
+ 'A330' => ['A330', 'A332', 'A333', 'A338', 'A339'],
+ 'A340' => ['A340', 'A342', 'A343', 'A345', 'A346'],
+ 'A350' => ['A350', 'A358', 'A359', 'A35K'],
+ 'A380' => ['A380', 'A388'],
];
protected function displayName() : Attribute{
diff --git a/app/Models/User.php b/app/Models/User.php
index f5691b2..a75ba02 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -55,6 +55,14 @@ class User extends Authenticatable
return $this->hasMany(UserFlight::class);
}
+ public function departedFlights() : HasMany {
+ return $this->flights()->where('departure_date', '<=', now('UTC'));
+ }
+
+ public function upcomingFlights() : HasMany {
+ return $this->flights()->where('departure_date', '>=', now('UTC'));
+ }
+
public function ImportedFlights(): HasMany
{
return $this->hasMany(ImportedFlight::class);
diff --git a/app/Services/Achievements/AchievementService.php b/app/Services/Achievements/AchievementService.php
index f3f03c4..ceeb0a7 100644
--- a/app/Services/Achievements/AchievementService.php
+++ b/app/Services/Achievements/AchievementService.php
@@ -59,7 +59,7 @@ class AchievementService
'arrivalAirport.region',
'departureAirport.region.continent',
'arrivalAirport.region.continent',
- ])->get();
+ ])->where('departure_date', '<=', now('UTC'))->get();
foreach ($this->checkers as $checkerClass) {
$checker = new $checkerClass($this);
diff --git a/app/Services/Achievements/Checkers/AircraftChecker.php b/app/Services/Achievements/Checkers/AircraftChecker.php
index 3ac243f..e0c7fa6 100644
--- a/app/Services/Achievements/Checkers/AircraftChecker.php
+++ b/app/Services/Achievements/Checkers/AircraftChecker.php
@@ -2,37 +2,13 @@
namespace App\Services\Achievements\Checkers;
+use App\Models\Aircraft;
use App\Models\User;
use App\Models\UserFlight;
use Illuminate\Support\Collection;
class AircraftChecker extends BaseChecker
{
- private const array BOEING_FAMILIES = [
- '707' => ['B701', 'B703', 'B720'],
- '717' => ['B712', 'B717'],
- '727' => ['B721', 'B722', 'B727'],
- '737' => ['B731', 'B732', 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', 'B37M', 'B38M', 'B39M'],
- '747' => ['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S'],
- '757' => ['B752', 'B753', 'B757'],
- '767' => ['B762', 'B763', 'B764', 'B767'],
- '777' => ['B772', 'B773', 'B77L', 'B77W', 'B778', 'B779'],
- '787' => ['B788', 'B789', 'B78X'],
- ];
-
- private const array AIRBUS_FAMILIES = [
- 'A300' => ['A30B', 'A300', 'A306'],
- 'A310' => ['A310', 'A312', 'A313'],
- 'A318' => ['A318'],
- 'A319' => ['A319', 'A31X'],
- 'A320' => ['A320', 'A20N'],
- 'A321' => ['A321', 'A21N'],
- 'A330' => ['A330', 'A332', 'A333', 'A338', 'A339'],
- 'A340' => ['A340', 'A342', 'A343', 'A345', 'A346'],
- 'A350' => ['A350', 'A358', 'A359', 'A35K'],
- 'A380' => ['A380', 'A388'],
- ];
-
private const array DOUBLE_DECKER_DESIGNATORS = [
// A380
'A380', 'A388',
@@ -121,7 +97,7 @@ class AircraftChecker extends BaseChecker
// --- Boeing 7x7 families ---
- $flownBoeingFamilies = collect(self::BOEING_FAMILIES)
+ $flownBoeingFamilies = collect(Aircraft::BOEING_FAMILIES)
->filter(fn($designators) =>
$flightsWithAircraft->contains(
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
@@ -133,7 +109,7 @@ class AircraftChecker extends BaseChecker
// --- Airbus A3xx families ---
- $flownAirbusFamilie = collect(self::AIRBUS_FAMILIES)
+ $flownAirbusFamilie = collect(Aircraft::AIRBUS_FAMILIES)
->filter(fn($designators) =>
$flightsWithAircraft->contains(
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
diff --git a/app/Services/Achievements/Checkers/CountriesAndContinentsChecker.php b/app/Services/Achievements/Checkers/CountriesAndContinentsChecker.php
index 812545a..a710ca1 100644
--- a/app/Services/Achievements/Checkers/CountriesAndContinentsChecker.php
+++ b/app/Services/Achievements/Checkers/CountriesAndContinentsChecker.php
@@ -76,6 +76,13 @@ class CountriesAndContinentsChecker extends BaseChecker
$dep = $flight->departureAirport->region->continent->internal_name;
$arr = $flight->arrivalAirport->region->continent->internal_name;
+ if (!in_array($dep, self::INHABITED_CONTINENTS) || !in_array($arr, self::INHABITED_CONTINENTS)) continue;
+ if ($dep === $arr) {
+ $depCountry = $flight->departureAirport->region->country_id;
+ $arrCountry = $flight->arrivalAirport->region->country_id;
+ if ($depCountry === $arrCountry) continue;
+ }
+
// Directed route key e.g. "europe→asia"
$directedRoutes->push("{$dep}→{$arr}");
diff --git a/app/Services/Achievements/Checkers/GeneralFlyingChecker.php b/app/Services/Achievements/Checkers/GeneralFlyingChecker.php
index 9db9479..8a2e140 100644
--- a/app/Services/Achievements/Checkers/GeneralFlyingChecker.php
+++ b/app/Services/Achievements/Checkers/GeneralFlyingChecker.php
@@ -70,6 +70,11 @@ class GeneralFlyingChecker extends BaseChecker
// --- Progressive achievements ---
+ $totalDistance = $flights->sum('distance');
+
+ $this->awardProgress((int) $totalDistance, 'general_flying.circumference_of_the_earth');
+ $this->awardProgress((int) $totalDistance, 'general_flying.to_the_moon');
+
$this->awardProgress($count,'general_flying.10_flights');
$this->awardProgress($count,'general_flying.50_flights');
$this->awardProgress($count,'general_flying.100_flights');
diff --git a/database/migrations/2026_05_16_105657_add_distance_achievement.php b/database/migrations/2026_05_16_105657_add_distance_achievement.php
new file mode 100644
index 0000000..4ba5cda
--- /dev/null
+++ b/database/migrations/2026_05_16_105657_add_distance_achievement.php
@@ -0,0 +1,102 @@
+ 'Circumnavigator',
+ 'internal_name' => 'general_flying.circumference_of_the_earth',
+ 'short_description' => 'Fly the same distance as the circumference of the Earth at the equator!',
+ 'icon' => 'standard_achievement.png',
+ 'progressive' => true,
+ 'long_description' => '',
+ 'achievement_category_id' => AchievementCategory::where('internal_name', 'general_flying')->first()->id,
+ 'achievement_difficulty_id' => AchievementDifficulty::where('internal_name', 'moderate')->first()->id,
+ 'threshold' => 40075,
+ 'has_page' => false,
+ ]);
+
+ Achievement::create([
+ 'name' => 'Fly Me to The Moon',
+ 'internal_name' => 'general_flying.to_the_moon',
+ 'short_description' => 'Fly the same distance as the Earth to the Moon!',
+ 'icon' => 'standard_achievement.png',
+ 'long_description' => '',
+ 'progressive' => true,
+ 'threshold' => 384400,
+ 'achievement_category_id' => AchievementCategory::where('internal_name', 'general_flying')->first()->id,
+ 'achievement_difficulty_id' => AchievementDifficulty::where('internal_name', 'hard')->first()->id,
+ 'has_page' => false,
+ ]);
+
+ Achievement::whereInternalName('aircraft.all_boeing_7x7')->update(['has_page' => true]);
+ Achievement::whereInternalName('aircraft.all_airbus_a3xx')->update(['has_page' => true]);
+ Airline::whereInternalName('south-africa-airways')->update(['name' => 'South African Airways']);
+
+ Schema::table('achievements', function (Blueprint $table) {
+ $table->unsignedInteger('sort_order')->nullable()->after('id');
+ });
+
+ // Seed sort_order from current id order, scoped per category
+ $achievements = DB::table('achievements')
+ ->orderBy('achievement_category_id')
+ ->orderBy('id')
+ ->get();
+
+ $position = 1;
+ $currentCategory = null;
+
+ foreach ($achievements as $achievement) {
+ if ($achievement->achievement_category_id !== $currentCategory) {
+ $position = 1;
+ $currentCategory = $achievement->achievement_category_id;
+ }
+ DB::table('achievements')
+ ->where('id', $achievement->id)
+ ->update(['sort_order' => $position++]);
+ }
+
+ // Move "Four on the Floor" (id 30) after "Triple Threat" (id 34)
+ // within the aircraft category — swap their sort_order values
+ $triEngine = DB::table('achievements')->where('internal_name', 'aircraft.tri_engine')->first();
+ $quadEngine = DB::table('achievements')->where('internal_name', 'aircraft.quad_engine')->first();
+
+ DB::table('achievements')->where('internal_name', 'aircraft.quad_engine')
+ ->update(['sort_order' => $triEngine->sort_order + 1]);
+
+ // Shift everything between them up by 1 to make room
+ DB::table('achievements')
+ ->where('achievement_category_id', $quadEngine->achievement_category_id)
+ ->where('sort_order', '>=', $triEngine->sort_order + 1)
+ ->where('internal_name', '!=', 'aircraft.quad_engine')
+ ->increment('sort_order');
+
+ $users = User::all();
+
+ foreach ($users as $user) {
+ $user->calculateAchievements();
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ //
+ }
+};
diff --git a/resources/css/app.css b/resources/css/app.css
index 367f383..f7e46df 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -103,12 +103,16 @@ body {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #778;
+ position: relative;
+ overflow: hidden;
}
.class-premium-global {
background: rgba(75, 32, 137, 0.35);
border: 1px solid rgba(180, 130, 255, 0.25);
color: #c49dff;
+ position: relative;
+ overflow: hidden;
}
.class-premium_economy-global {
diff --git a/resources/js/Components/Distance.vue b/resources/js/Components/Distance.vue
new file mode 100644
index 0000000..8801fb4
--- /dev/null
+++ b/resources/js/Components/Distance.vue
@@ -0,0 +1,40 @@
+
+
+
+ {{includeSpace ? ' ' : ''}}{{ showUnits ? LABELS[unit] : '' }}
+
+
+
diff --git a/resources/js/Components/FlightsGoneBy/AchievementCard.vue b/resources/js/Components/FlightsGoneBy/AchievementCard.vue
index 49971e5..c088df9 100644
--- a/resources/js/Components/FlightsGoneBy/AchievementCard.vue
+++ b/resources/js/Components/FlightsGoneBy/AchievementCard.vue
@@ -5,6 +5,13 @@ import {computed} from "vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import {Link} from '@inertiajs/vue3'
+import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
+import Distance from "@/Components/Distance.vue";
+
+const distanceAchievements = [
+ 'general_flying.circumference_of_the_earth',
+ 'general_flying.to_the_moon',
+];
const props = defineProps<{
achievement: Achievement
@@ -85,13 +92,11 @@ const difficultyVariant = computed(() => {
{{ achievement.short_description }}
-
- View Details
-
+
- {{ Math.min(progress.current, progress.threshold) }} / {{ progress.threshold }}
+ /
{{ progress.percentage }}%
{
bg-color="rgba(255,255,255,0.1)"
/>
-
-
+
+
+
+
@@ -115,19 +126,17 @@ const difficultyVariant = computed(() => {
transition: opacity 0.2s ease, transform 0.2s ease;
}
-.achievement-card.locked {
+.achievement-card.locked .achievement-inner {
opacity: 0.45;
}
-.achievement-card:hover {
- opacity: 1;
- transform: translateY(-1px);
-}
+.achievement-card:hover .achievement-inner,
.achievement-inner {
display: flex;
gap: 1rem;
align-items: flex-start;
+ opacity: 1;
}
.achievement-icon-wrap {
diff --git a/resources/js/Components/FlightsGoneBy/AirlineAlphabetTable.vue b/resources/js/Components/FlightsGoneBy/AirlineAlphabetTable.vue
new file mode 100644
index 0000000..360cd9b
--- /dev/null
+++ b/resources/js/Components/FlightsGoneBy/AirlineAlphabetTable.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+ {{ letter }}
+
+
+
+
+
+
+ {{ entry.code }}
+
+
+
+
+
+
+
+
diff --git a/resources/js/Components/FlightsGoneBy/AllianceChallenge.vue b/resources/js/Components/FlightsGoneBy/AllianceChallenge.vue
new file mode 100644
index 0000000..2aa982b
--- /dev/null
+++ b/resources/js/Components/FlightsGoneBy/AllianceChallenge.vue
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ entryFor(key).airline.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Requirements
+
+ To complete this challenge you must fly with every current member airline of
+ {{ alliance.name }} . Alliance membership changes over time, so the
+ required airlines reflect the current roster.
+
+
+
+ Codeshare flights do not count, the operating carrier must be a member of {{alliance.name}}.
+
+
+
+
+
diff --git a/resources/js/Components/FlightsGoneBy/AlphabetTable.vue b/resources/js/Components/FlightsGoneBy/AlphabetTable.vue
new file mode 100644
index 0000000..f1b025e
--- /dev/null
+++ b/resources/js/Components/FlightsGoneBy/AlphabetTable.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+ {{ letter }}
+
+
+
+
+
+
+ {{ getCode(entry.airport) }}
+
+
+
+
+
+
+
diff --git a/resources/js/Components/FlightsGoneBy/BoardingPass.vue b/resources/js/Components/FlightsGoneBy/BoardingPass.vue
index af3fa25..e18f386 100644
--- a/resources/js/Components/FlightsGoneBy/BoardingPass.vue
+++ b/resources/js/Components/FlightsGoneBy/BoardingPass.vue
@@ -3,6 +3,7 @@ import { Flight } from "@/Types/types";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
+import Distance from "@/Components/Distance.vue";
defineProps<{
flight: Flight
@@ -70,7 +71,7 @@ defineProps<{
·
DISTANCE
- {{ Math.round(flight.distance).toLocaleString() }} km
+
@@ -80,16 +81,6 @@ defineProps<{
diff --git a/resources/js/Components/FlightsGoneBy/DepartureBoard.vue b/resources/js/Components/FlightsGoneBy/DepartureBoard.vue
index b8b2149..4ddda8f 100644
--- a/resources/js/Components/FlightsGoneBy/DepartureBoard.vue
+++ b/resources/js/Components/FlightsGoneBy/DepartureBoard.vue
@@ -13,6 +13,7 @@ import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import CrewTooltip from "@/Components/FlightsGoneBy/CrewTooltip.vue";
import {Link, router} from "@inertiajs/vue3";
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue";
+import Distance from "@/Components/Distance.vue";
const props = defineProps<{
flightStats: FlightStats
@@ -238,7 +239,7 @@ watch(
- {{ (item as Flight).distance ? Math.round((item as Flight).distance).toLocaleString() + ' km' : '' }}
+
diff --git a/resources/js/Components/FlightsGoneBy/Feed/FlightBookedFeedItem.vue b/resources/js/Components/FlightsGoneBy/Feed/FlightBookedFeedItem.vue
index 9eaa681..891942a 100644
--- a/resources/js/Components/FlightsGoneBy/Feed/FlightBookedFeedItem.vue
+++ b/resources/js/Components/FlightsGoneBy/Feed/FlightBookedFeedItem.vue
@@ -10,7 +10,7 @@ const props = defineProps<{
-
+
diff --git a/resources/js/Components/FlightsGoneBy/FlightBadge.vue b/resources/js/Components/FlightsGoneBy/FlightBadge.vue
new file mode 100644
index 0000000..f909d36
--- /dev/null
+++ b/resources/js/Components/FlightsGoneBy/FlightBadge.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ {{ flight.flight_number || `${flight.departure_airport.display_code}-${flight.arrival_airport.display_code}` }}
+
+
+
+
+
diff --git a/resources/js/Components/FlightsGoneBy/FlightMap.vue b/resources/js/Components/FlightsGoneBy/FlightMap.vue
index 4507223..da91656 100644
--- a/resources/js/Components/FlightsGoneBy/FlightMap.vue
+++ b/resources/js/Components/FlightsGoneBy/FlightMap.vue
@@ -471,6 +471,7 @@ export default defineComponent({
map = new maplibregl.Map({
container: mapContainer.value!,
cooperativeGestures: true,
+ attributionControl: false,
style: {
version: 8,
sources: {
@@ -483,7 +484,6 @@ export default defineComponent({
'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
- attribution: '© OpenStreetMap © CARTO ',
maxzoom: 19,
},
},
diff --git a/resources/js/Components/FlightsGoneBy/FlightRegionTable.vue b/resources/js/Components/FlightsGoneBy/FlightRegionTable.vue
index 1bb95c5..66cea27 100644
--- a/resources/js/Components/FlightsGoneBy/FlightRegionTable.vue
+++ b/resources/js/Components/FlightsGoneBy/FlightRegionTable.vue
@@ -1,49 +1,47 @@
-
-
- {{ regionNames?.[code] ?? code }}
-
-
-
-
-
-
- {{ flight.flight_number || `${flight.departure_airport.display_code}-${flight.arrival_airport.display_code}` }}
-
-
-
-
-
-
-
-
-
+
+
+ {{ regionNames?.[code] ?? code }}
+
+
+
+
+
+
diff --git a/resources/js/Components/FlightsGoneBy/FlightStatsBar.vue b/resources/js/Components/FlightsGoneBy/FlightStatsBar.vue
index 27287b9..5ed200a 100644
--- a/resources/js/Components/FlightsGoneBy/FlightStatsBar.vue
+++ b/resources/js/Components/FlightsGoneBy/FlightStatsBar.vue
@@ -35,18 +35,13 @@
- {{ totalDistanceKm.toLocaleString() }}
- km
+
- {{ totalDistanceMi.toLocaleString() }} miles
- {{ upcomingDistanceKm.toLocaleString() }}
- {{ totalDistanceKm ? 'km upcoming' : 'km' }}
- upcoming
+
- {{ upcomingDistanceMi.toLocaleString() }} miles
@@ -105,6 +100,7 @@
+
+
+
+
+
+
diff --git a/resources/js/Components/FlightsGoneBy/GlassTooltip.vue b/resources/js/Components/FlightsGoneBy/GlassTooltip.vue
index e87b83b..14b03a4 100644
--- a/resources/js/Components/FlightsGoneBy/GlassTooltip.vue
+++ b/resources/js/Components/FlightsGoneBy/GlassTooltip.vue
@@ -29,8 +29,6 @@ defineProps<{
}
.glass-tooltip {
- background: var(--surface);
- border: 1px solid var(--table-border);
padding: 10px 14px;
min-width: 180px;
display: flex;
@@ -38,5 +36,6 @@ defineProps<{
gap: 8px;
color: var(--text);
font-size: 0.85rem;
+ z-index: 20000
}
diff --git a/resources/js/Components/FlightsGoneBy/InlineBadge.vue b/resources/js/Components/FlightsGoneBy/InlineBadge.vue
index 2186b53..25d2102 100644
--- a/resources/js/Components/FlightsGoneBy/InlineBadge.vue
+++ b/resources/js/Components/FlightsGoneBy/InlineBadge.vue
@@ -17,7 +17,7 @@ withDefaults(defineProps<{
diff --git a/resources/js/Components/FlightsGoneBy/ProfileHeader.vue b/resources/js/Components/FlightsGoneBy/ProfileHeader.vue
index 0c92f44..cb6dfd0 100644
--- a/resources/js/Components/FlightsGoneBy/ProfileHeader.vue
+++ b/resources/js/Components/FlightsGoneBy/ProfileHeader.vue
@@ -9,6 +9,7 @@ const props = defineProps<{
flightCount?: number
achievementCount?: number
isFollowing?: boolean
+ show: "flights" | "achievements"
}>()
const auth = usePage().props.auth
@@ -20,6 +21,13 @@ const processing = ref(false)
const snackbar = ref(false)
const snackbarMessage = ref('')
+const counts = computed(() => {
+ return {
+ flights: props.flightCount ?? 0,
+ achievements: props.achievementCount ?? 0,
+ } as Record<"flights" | "achievements", number>
+})
+
const follow = async () => {
processing.value = true
const response = await fetch(route('profile.follow', { user: props.user.name }), {
@@ -61,8 +69,8 @@ const follow = async () => {
- {{ flightCount ?? achievementCount }}
- {{achievementCount ? 'Achievements' : 'Flights'}}
+ {{ counts[show] }}
+ {{show.toUpperCase()}}
@@ -114,6 +122,12 @@ const follow = async () => {
margin: 0;
}
+@media (max-width: 768px) {
+ .board-header {
+ padding: 1em
+ }
+}
+
.follow-btn {
font-family: 'Share Tech Mono', monospace;
font-size: 0.75rem;
diff --git a/resources/js/Components/FlightsGoneBy/ProfileLayout.vue b/resources/js/Components/FlightsGoneBy/ProfileLayout.vue
index f6c3cd9..f4f1ad3 100644
--- a/resources/js/Components/FlightsGoneBy/ProfileLayout.vue
+++ b/resources/js/Components/FlightsGoneBy/ProfileLayout.vue
@@ -14,7 +14,7 @@ defineProps<{
-
+
diff --git a/resources/js/Components/FormattedNumber.vue b/resources/js/Components/FormattedNumber.vue
new file mode 100644
index 0000000..1017ba2
--- /dev/null
+++ b/resources/js/Components/FormattedNumber.vue
@@ -0,0 +1,20 @@
+
+
+
+ {{ formatted }}
+
+
+
diff --git a/resources/js/Components/Maps/Brazil.vue b/resources/js/Components/Maps/Brazil.vue
new file mode 100644
index 0000000..30a527c
--- /dev/null
+++ b/resources/js/Components/Maps/Brazil.vue
@@ -0,0 +1,1811 @@
+
+
+
+
+Maranhão
+
+
+
+
+
+
+
+
+
+ Pernambuco
+
+
+ Espírito Santo
+ Amapá
+
+
+
+
+
+
+
+ Rio de Janeiro
+
+
+
+ São Paulo
+
+
+
+
+
+
+ Alagoas
+
+
+ Rio Grande do Sul
+
+
+ Paraíba
+
+
+
+
+ Rio Grande do Norte
+ Santa Catarina
+
+
+
+
+ Bahia
+
+
+
+
+
+ Roraima
+
+
+ Ceará
+
+
+
+
+
+
+ Sergipe
+
+
+
+ Paraná
+
+
+
+ Amazonas
+
+
+
+
+ Minas Gerais
+
+
+ Rondônia
+ Piauí
+
+
+
+
+ Mato Grosso
+
+
+ Acre
+
+
+
+
+ Tocantins
+
+
+ Goiás
+ Distrito Federal
+ Mato Grosso do Sul
+
+
+ Pará
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/Composables/useAircraftFamilies.ts b/resources/js/Composables/useAircraftFamilies.ts
new file mode 100644
index 0000000..eafee78
--- /dev/null
+++ b/resources/js/Composables/useAircraftFamilies.ts
@@ -0,0 +1,51 @@
+import { computed, ComputedRef } from 'vue'
+import { Flight } from '@/Types/types'
+
+// ── Types ──────────────────────────────────────────────────────────────────
+
+export interface AircraftFamilyEntry {
+ family: string
+ designators: string[]
+ flights: Flight[]
+}
+
+// ── Composable ─────────────────────────────────────────────────────────────
+
+export function useAircraftFamilies(
+ flights: ComputedRef
,
+ families: Record,
+) {
+ // Build a lookup from designator → family name for O(1) matching
+ const designatorToFamily = Object.entries(families).reduce>(
+ (acc, [family, designators]) => {
+ for (const d of designators) acc[d] = family
+ return acc
+ },
+ {}
+ )
+
+ const entries = computed(() => {
+ // Pre-populate all families with empty flight lists
+ const map = new Map(
+ Object.entries(families).map(([family, designators]) => [
+ family,
+ { family, designators, flights: [] },
+ ])
+ )
+
+ for (const flight of flights.value) {
+ const designator = flight.aircraft?.designator
+ if (!designator) continue
+ const family = designatorToFamily[designator]
+ if (!family) continue
+ map.get(family)!.flights.push(flight)
+ }
+
+ return [...map.values()]
+ })
+
+ const completedCount = computed(() => entries.value.filter(e => e.flights.length > 0).length)
+ const totalCount = computed(() => entries.value.length)
+
+ return { entries, completedCount, totalCount }
+}
diff --git a/resources/js/Composables/useAlphabetAirlines.ts b/resources/js/Composables/useAlphabetAirlines.ts
new file mode 100644
index 0000000..43f13ec
--- /dev/null
+++ b/resources/js/Composables/useAlphabetAirlines.ts
@@ -0,0 +1,43 @@
+import { computed, type Ref } from 'vue'
+import type { Flight } from '@/Types/types'
+
+export type CodeType = 'iata' | 'icao'
+
+const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
+const digits = '0123456789'.split('')
+
+export function getAllLetters(showNumbers: boolean): string[] {
+ return showNumbers ? [...digits, ...letters] : letters
+}
+
+export function useAlphabetAirlines(
+ flights: Ref,
+ codeType: Ref,
+ showNumbers: Ref,
+) {
+ const allLetters = computed(() => getAllLetters(showNumbers.value))
+
+ const flightsByLetter = computed(() => {
+ const map: Record = {}
+
+ for (const flight of flights.value) {
+ const raw = codeType.value === 'iata'
+ ? flight.airline?.IATA_code
+ : flight.airline?.ICAO_code
+
+ const code = raw?.trim().toUpperCase()
+ if (!code) continue
+
+ const key = code[0]
+ if (!allLetters.value.includes(key)) continue
+
+ ;(map[key] ??= []).push(flight)
+ }
+
+ return map
+ })
+
+ const visitedLetters = computed(() => new Set(Object.keys(flightsByLetter.value)))
+
+ return { flightsByLetter, visitedLetters, allLetters }
+}
diff --git a/resources/js/Composables/useAlphabetFlights.ts b/resources/js/Composables/useAlphabetFlights.ts
new file mode 100644
index 0000000..b4b09ae
--- /dev/null
+++ b/resources/js/Composables/useAlphabetFlights.ts
@@ -0,0 +1,81 @@
+import { computed, type ComputedRef, type Ref } from 'vue'
+import type { Flight } from '@/Types/types'
+
+export type CodeType = 'iata' | 'icao'
+
+export interface AlphabetFlightData {
+ /** All 26 uppercase letters A–Z */
+ allLetters: string[]
+ /** Letters the user has visited (has at least one qualifying airport) */
+ visitedLetters: ComputedRef>
+ /** Map of letter → flights that touch an airport starting with that letter */
+ flightsByLetter: ComputedRef>
+ /** The code type being used */
+ codeType: CodeType
+}
+
+/**
+ * Returns which letters of the alphabet have been "visited" based on
+ * the IATA or ICAO code of the departure or arrival airport on each flight.
+ *
+ * A letter is considered visited when at least one flight departs from or
+ * arrives at an airport whose chosen code starts with that letter.
+ *
+ * Airports without a code of the chosen type are ignored.
+ */
+export function useAlphabetFlights(
+ flights: Ref | ComputedRef,
+ codeType: CodeType = 'iata',
+): AlphabetFlightData {
+ const allLetters = Array.from({ length: 26 }, (_, i) =>
+ String.fromCharCode(65 + i), // 'A' … 'Z'
+ )
+
+ /** Pick the right code from an airport, uppercased first character or null */
+ function getCode(airport: Flight['departure_airport'] | Flight['arrival_airport']): string | null {
+ const raw = codeType === 'iata' ? airport.iata_code : airport.icao_code
+ return raw ? raw.trim().toUpperCase() : null
+ }
+
+ const flightsByLetter = computed>(() => {
+ const map: Record = {}
+ for (const letter of allLetters) {
+ map[letter] = []
+ }
+
+ for (const flight of flights.value) {
+ const codes = new Set()
+
+ const depCode = getCode(flight.departure_airport)
+ if (depCode) codes.add(depCode[0])
+
+ const arrCode = getCode(flight.arrival_airport)
+ if (arrCode) codes.add(arrCode[0])
+
+ for (const letter of codes) {
+ if (letter >= 'A' && letter <= 'Z') {
+ map[letter]?.push(flight)
+ }
+ }
+ }
+
+ return map
+ })
+
+ const visitedLetters = computed>(() => {
+ const visited = new Set()
+ for (const [letter, letterFlights] of Object.entries(flightsByLetter.value)) {
+ if (letterFlights.length > 0) {
+ visited.add(letter)
+ }
+ }
+ return visited
+ })
+
+ return {
+ allLetters,
+ visitedLetters,
+ flightsByLetter,
+ codeType,
+ }
+}
diff --git a/resources/js/Composables/useContinentPairs.ts b/resources/js/Composables/useContinentPairs.ts
new file mode 100644
index 0000000..e8076bf
--- /dev/null
+++ b/resources/js/Composables/useContinentPairs.ts
@@ -0,0 +1,164 @@
+import { computed, ComputedRef } from 'vue'
+import { Flight } from '@/Types/types'
+
+export interface Continent {
+ id: number
+ name: string
+ code: string
+ internal_name: string
+}
+
+export interface ContinentPairEntry {
+ key: string
+ label: string
+ flights: Flight[]
+}
+
+/** Stable undirected key — always alphabetically sorted so A|B === B|A */
+export function undirectedKey(a: string, b: string): string {
+ return [a, b].sort().join('|')
+}
+
+/** Directed key — order preserved */
+export function directedKey(dep: string, arr: string): string {
+ return `${dep}|${arr}`
+}
+
+export function labelFor(a: string, b: string): string {
+ return `${a} ↔ ${b}`
+}
+
+export function continentNameOf(flight: Flight, side: 'departure' | 'arrival'): string | null {
+ const airport = side === 'departure' ? flight.departure_airport : flight.arrival_airport
+ return airport.region?.continent?.name ?? null
+}
+
+export function isInternational(flight: Flight): boolean {
+ const depCountry = flight.departure_airport.region?.country_id
+ const arrCountry = flight.arrival_airport.region?.country_id
+ if (depCountry == null || arrCountry == null) return true
+ return depCountry !== arrCountry
+}
+
+/** Filter flights to only those that qualify for continent-pair achievements */
+export function qualifyingFlights(flights: Flight[]): Flight[] {
+ return flights.filter(flight => {
+ const dep = continentNameOf(flight, 'departure')
+ const arr = continentNameOf(flight, 'arrival')
+ if (!dep || !arr) return false
+ if (dep === arr && !isInternational(flight)) return false
+ return true
+ })
+}
+
+// ── One-way (undirected) pairs ─────────────────────────────────────────────
+
+export function useUndirectedContinentPairs(
+ flights: ComputedRef,
+ continents: ComputedRef,
+) {
+ const allKeys = computed(() => {
+ const names = continents.value.map(c => c.name).sort()
+ const keys: string[] = []
+ for (let i = 0; i < names.length; i++) {
+ for (let j = i; j < names.length; j++) {
+ keys.push(undirectedKey(names[i], names[j]))
+ }
+ }
+ return keys.sort((a, b) => {
+ const [a1, a2] = a.split('|')
+ const [b1, b2] = b.split('|')
+ return labelFor(a1, a2).localeCompare(labelFor(b1, b2))
+ })
+ })
+
+ const flightsByKey = computed>(() => {
+ const map = new Map(allKeys.value.map(k => [k, [] as Flight[]]))
+ for (const flight of qualifyingFlights(flights.value)) {
+ const dep = continentNameOf(flight, 'departure')!
+ const arr = continentNameOf(flight, 'arrival')!
+ const key = undirectedKey(dep, arr)
+ map.get(key)?.push(flight)
+ }
+ return map
+ })
+
+ const entries = computed(() =>
+ allKeys.value.map(key => {
+ const [a, b] = key.split('|')
+ return { key, label: labelFor(a, b), flights: flightsByKey.value.get(key) ?? [] }
+ })
+ )
+
+ const completedCount = computed(() => entries.value.filter(e => e.flights.length > 0).length)
+ const totalCount = computed(() => entries.value.length)
+
+ return { entries, completedCount, totalCount }
+}
+
+// ── Both-ways (directed) pairs ─────────────────────────────────────────────
+
+export function useDirectedContinentPairs(
+ flights: ComputedRef,
+ continents: ComputedRef,
+) {
+ /** All directed keys grouped by the departure continent name */
+ const keysByDeparture = computed>(() => {
+ const names = continents.value.map(c => c.name).sort()
+ const map = new Map()
+ for (const dep of names) {
+ map.set(dep, names.map(arr => directedKey(dep, arr)).sort((a, b) => {
+ const [, a2] = a.split('|')
+ const [, b2] = b.split('|')
+ return a2.localeCompare(b2)
+ }))
+ }
+ return map
+ })
+
+ const flightsByKey = computed>(() => {
+ const map = new Map()
+ for (const keys of keysByDeparture.value.values()) {
+ for (const key of keys) map.set(key, [])
+ }
+ for (const flight of qualifyingFlights(flights.value)) {
+ const dep = continentNameOf(flight, 'departure')!
+ const arr = continentNameOf(flight, 'arrival')!
+ const key = directedKey(dep, arr)
+ map.get(key)?.push(flight)
+ }
+ return map
+ })
+
+ /** Entries grouped by departure continent, each sorted by arrival continent name */
+ const entriesByDeparture = computed>(() => {
+ const map = new Map()
+ for (const [dep, keys] of keysByDeparture.value) {
+ map.set(dep, keys.map(key => {
+ const [a, b] = key.split('|')
+ return { key, label: `${a} → ${b}`, flights: flightsByKey.value.get(key) ?? [] }
+ }))
+ }
+ return map
+ })
+
+ const departureNames = computed(() => [...keysByDeparture.value.keys()])
+
+ const completedCount = computed(() => {
+ let count = 0
+ for (const entries of entriesByDeparture.value.values()) {
+ count += entries.filter(e => e.flights.length > 0).length
+ }
+ return count
+ })
+
+ const totalCount = computed(() => {
+ let count = 0
+ for (const entries of entriesByDeparture.value.values()) {
+ count += entries.length
+ }
+ return count
+ })
+
+ return { entriesByDeparture, departureNames, completedCount, totalCount }
+}
diff --git a/resources/js/Composables/useFlightStats.ts b/resources/js/Composables/useFlightStats.ts
index 7b2ebca..5319096 100644
--- a/resources/js/Composables/useFlightStats.ts
+++ b/resources/js/Composables/useFlightStats.ts
@@ -33,7 +33,6 @@ function getDateParts(f: Flight): { year: number; month: number; day: number } {
// ── Per year / month / day ────────────────────────────────────────────────────
export function getFlightsPerYear(flights: Flight[], upcomingFlights: Flight[]) {
- console.time('getFlightsPerYear')
const allFlights = [...flights, ...upcomingFlights]
const allYears = new Set(allFlights.map(f => getDateParts(f).year))
@@ -57,12 +56,10 @@ export function getFlightsPerYear(flights: Flight[], upcomingFlights: Flight[])
{ name: 'Upcoming', data: countByYear(upcomingFlights) },
],
}
- console.timeEnd('getFlightsPerYear')
return result
}
export function getFlightsPerMonth(flights: Flight[], upcomingFlights: Flight[]) {
- console.time('getFlightsPerMonth')
const countByMonth = (list: Flight[]) =>
MONTHS.map((_, i) => list.filter(f => getDateParts(f).month === i).length)
@@ -73,12 +70,10 @@ export function getFlightsPerMonth(flights: Flight[], upcomingFlights: Flight[])
{ name: 'Upcoming', data: countByMonth(upcomingFlights) },
],
}
- console.timeEnd('getFlightsPerMonth')
return result
}
export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
- console.time('getFlightsPerDay')
const countByDay = (list: Flight[]) =>
DAYS.map((_, i) => list.filter(f => getDateParts(f).day === i).length)
@@ -89,7 +84,6 @@ export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
{ name: 'Upcoming', data: countByDay(upcomingFlights) },
],
}
- console.timeEnd('getFlightsPerDay')
return result
}
@@ -109,23 +103,17 @@ function groupByName(flights: Flight[], accessor: (f: Flight) => string) {
}
export function getFlightReasons(flights: Flight[]) {
- console.time('getFlightReasons')
const result = groupByName(flights, f => f.flight_reason?.name ?? 'Unknown')
- console.timeEnd('getFlightReasons')
return result
}
export function getFlightClasses(flights: Flight[]) {
- console.time('getFlightClasses')
const result = groupByName(flights, f => f.flight_class?.name ?? 'Unknown')
- console.timeEnd('getFlightClasses')
return result
}
export function getSeatTypes(flights: Flight[]) {
- console.time('getSeatTypes')
const result = groupByName(flights, f => f.seat_type?.name ?? 'Unknown')
- console.timeEnd('getSeatTypes')
return result
}
@@ -152,7 +140,6 @@ function countCountries(flights: Flight[]) {
}
export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
- console.time('getCountries')
const past = countCountries(flights)
const upcoming = countCountries(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
@@ -173,14 +160,12 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
- console.timeEnd('getCountries')
return result
}
// ── Continents ────────────────────────────────────────────────────────────────
export function getContinents(flights: Flight[]) {
- console.time('getContinents')
const counts = new Map()
flights.forEach(f => {
const continents = new Set()
@@ -195,7 +180,6 @@ export function getContinents(flights: Flight[]) {
labels: sorted.map(([name]) => name),
series: sorted.map(([, count]) => count),
}
- console.timeEnd('getContinents')
return result
}
@@ -214,7 +198,6 @@ function countAirlines(flights: Flight[]) {
}
export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
- console.time('getTopAirlines')
const past = countAirlines(flights)
const upcoming = countAirlines(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
@@ -236,7 +219,6 @@ export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
- console.timeEnd('getTopAirlines')
return result
}
@@ -256,7 +238,6 @@ function airportLabel(airport: Airport | null | undefined): string | null {
}
export function getTopAirports(flights: Flight[], upcomingFlights: Flight[]) {
- console.time('getTopAirports')
const map = new Map()
const empty = (): AirportItem => ({ departures: 0, arrivals: 0, upcoming: 0, label: '', fullName: '' })
@@ -313,7 +294,6 @@ export function getTopAirports(flights: Flight[], upcomingFlights: Flight[]) {
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
- console.timeEnd('getTopAirports')
return result
}
@@ -421,7 +401,6 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
// ── Flight types ──────────────────────────────────────────────────────────────
export function getFlightTypes(flights: Flight[]) {
- console.time('getFlightTypes')
const counts = { International: 0, Domestic: 0 }
flights.forEach(f => {
const dep = f.departure_airport.region?.country?.id
@@ -435,7 +414,6 @@ export function getFlightTypes(flights: Flight[]) {
labels: sorted.map(([name]) => name),
series: sorted.map(([, count]) => count),
}
- console.timeEnd('getFlightTypes')
return result
}
@@ -447,22 +425,16 @@ export function useFlightStats(flights: Ref) {
const now = new Date()
watch(flights, (list) => {
- console.time('dateCache warm')
list.forEach(f => getDateParts(f))
- console.timeEnd('dateCache warm')
}, { immediate: true })
const pastFlights = computed(() => {
- console.time('pastFlights')
const result = flights.value.filter(f => new Date(f.departure_date) <= now)
- console.timeEnd('pastFlights')
return result
})
const upcomingFlights = computed(() => {
- console.time('upcomingFlights')
const result = flights.value.filter(f => new Date(f.departure_date) > now)
- console.timeEnd('upcomingFlights')
return result
})
diff --git a/resources/js/Composables/useFlights.ts b/resources/js/Composables/useFlights.ts
index 4dab7d5..3cdd465 100644
--- a/resources/js/Composables/useFlights.ts
+++ b/resources/js/Composables/useFlights.ts
@@ -3,13 +3,15 @@ import {onMounted, ref} from "vue";
import {Flight} from "@/Types/types";
import axios from "axios";
-export function useFlights(url: string) {
+export function useFlights(url: string, departedOnly: boolean = false) {
const flights = ref([])
const flightsLoading = ref(true)
onMounted(async () => {
try {
- const response = await axios.get(url)
+ const response = await axios.get(url, {
+ params: departedOnly ? { departed_only: true } : {}
+ })
flights.value = response.data
} finally {
flightsLoading.value = false
diff --git a/resources/js/Composables/useRegionFlights.ts b/resources/js/Composables/useRegionFlights.ts
index dded824..7f5644a 100644
--- a/resources/js/Composables/useRegionFlights.ts
+++ b/resources/js/Composables/useRegionFlights.ts
@@ -21,7 +21,7 @@ export function useRegionFlights(
})
const flightsByRegion = computed(() => {
- return filteredFlights.value.reduce((grouped, flight) => {
+ const grouped = filteredFlights.value.reduce((grouped, flight) => {
const dep = flight.departure_airport?.region
const arr = flight.arrival_airport?.region
@@ -37,6 +37,14 @@ export function useRegionFlights(
return grouped
}, {} as Record)
+
+ return Object.fromEntries(
+ Object.entries(grouped).sort(([codeA], [codeB]) => {
+ const nameA = regionNames.value[codeA] ?? codeA
+ const nameB = regionNames.value[codeB] ?? codeB
+ return nameA.localeCompare(nameB)
+ })
+ )
})
const regionNames = computed(() => {
diff --git a/resources/js/Pages/Profile/Achievements/aircraft.all_airbus_a3xx.vue b/resources/js/Pages/Profile/Achievements/aircraft.all_airbus_a3xx.vue
new file mode 100644
index 0000000..29598b2
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/aircraft.all_airbus_a3xx.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+ Airbus {{ entry.family }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Requirements
+
+ To complete this challenge you must fly on at least one aircraft from every
+ Airbus A3xx family — the A300, A310, A318, A319, A320, A321, A330, A340, A350,
+ and A380.
+
+
+ Any variant within a family counts. For example, an Airbus A320neo (A20N) and
+ a classic A320 both satisfy the A320 family requirement.
+
+
+
+ Difficulty
+
+ If you are starting this challenge today, then this challenge is near-impossible to complete and will soon be impossible. At the time of writing, the following aircraft are very hard to fly on:
+
+
+ Airbus A300 - Only in service in Iran, mostly domestically. Very hard to arrange.
+ Airbus A310 - In commercial service in Iran and Afghanistan. Afghanistan is far less sanctioned, so easier to book a flight. Dubai to Kabul is a reliabe route to see the A310. An A310 also acts as a vomit comet in Europe
+ Airbus A318 - Very few left operating for Air France. Will likely be retired before you have read this.
+ Airbus A340 - Rapidly being retired from service in the western world with Lufthansa being the last hold out (at time of writing). Iran and Venezuela are likely to hold on to them for sometime. South African always seems to bring them back into service too. A few more scattered around in charter or smaller national airlines
+
+
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/aircraft.all_boeing_7x7.vue b/resources/js/Pages/Profile/Achievements/aircraft.all_boeing_7x7.vue
new file mode 100644
index 0000000..5114f44
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/aircraft.all_boeing_7x7.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+ Boeing {{ entry.family }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Requirements
+
+ To complete this challenge you must fly on at least one aircraft from every
+ Boeing 7x7 family — the 707, 717, 727, 737, 747, 757, 767, 777, and 787.
+
+
+ Any variant within a family counts. For example, a Boeing 737-800 (B738) and
+ a Boeing 737 MAX 8 (B38M) both satisfy the 737 family requirement.
+
+
+
+ Difficulty
+
+ If you are starting this challenge today, then this challenge is impossible to complete and will not count towards your achievement total. If you have historically completed it, it will.
+
+
+ Difficult types to fly on
+
+
+ Boeing 707 - Completely impossible to fly today. The US Military still operates some derivatives, but they do not count towards this challenge
+ Boeing 727 - No commercial service. One operates as a vomit comet in the USA. Very expensive, but technically possible to fly on
+ Boeing 717 - Still in service, mostly in the US, but being withdrawn rapidly.
+ Boeing 747 - Being pulled from service rapidly. Almost no 747-400s left in service (just Lufthansa). 747-8s are still active with Korean Air, Air China and Lufthansa but the US Air Force has acquired a large number from these airlines and they are dwindling
+ Boeing 757 - Still fairly common in the USA but quickly going extinct elsewhere.
+
+
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/airlines_alliances.all_oneworld.vue b/resources/js/Pages/Profile/Achievements/airlines_alliances.all_oneworld.vue
new file mode 100644
index 0000000..7a536e9
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/airlines_alliances.all_oneworld.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+ The Oneworld Alliance is currently the smallest of the big 3 major alliances, but with members all around the world it's no mean feat to get on all of them.
+
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/airlines_alliances.all_skyteam.vue b/resources/js/Pages/Profile/Achievements/airlines_alliances.all_skyteam.vue
new file mode 100644
index 0000000..4a3522a
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/airlines_alliances.all_skyteam.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+ The SkyTeam Alliance is the youngest of the 3 major alliances, but does not have the smallest roster!
+
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/airlines_alliances.all_star_alliance.vue b/resources/js/Pages/Profile/Achievements/airlines_alliances.all_star_alliance.vue
new file mode 100644
index 0000000..0ec5ae1
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/airlines_alliances.all_star_alliance.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+ Fly with every member airline of Star Alliance , the world's largest airline
+ alliance by number of member carriers.
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/airlines_alliances.all_vanilla_alliance.vue b/resources/js/Pages/Profile/Achievements/airlines_alliances.all_vanilla_alliance.vue
new file mode 100644
index 0000000..5f2e96a
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/airlines_alliances.all_vanilla_alliance.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+ Vanilla Alliance is a small, regional alliance in the Vanilla Islands. It is a paper alliance that is effectively defunct, but it's still a fun one to try for!
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/countries_continents.all_continent_pairs_both_ways.vue b/resources/js/Pages/Profile/Achievements/countries_continents.all_continent_pairs_both_ways.vue
new file mode 100644
index 0000000..6f24405
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/countries_continents.all_continent_pairs_both_ways.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ entry.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Requirements
+
+ To complete this challenge you must fly between every possible pair of continents
+ in both directions — a flight from Europe to Asia and a separate
+ flight from Asia to Europe are each required.
+
+
+ For same-continent routes (e.g. Africa → Africa), only
+ international flights count — domestic flights within the same
+ country are excluded.
+
+
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/countries_continents.all_continent_pairs_one_way.vue b/resources/js/Pages/Profile/Achievements/countries_continents.all_continent_pairs_one_way.vue
new file mode 100644
index 0000000..5cdb858
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/countries_continents.all_continent_pairs_one_way.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+ {{ entry.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Requirements
+
+ To complete this challenge you must fly at least one flight between every possible
+ pair of continents — including flights within the same continent.
+
+
+ For same-continent pairs (e.g. Africa ↔ Africa), only
+ international flights count — domestic flights within the same
+ country are excluded.
+
+
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.airline_alphabet.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.airline_alphabet.vue
new file mode 100644
index 0000000..cb0a898
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/fun_challenges.airline_alphabet.vue
@@ -0,0 +1,278 @@
+
+
+
+
+
+ The Airline Alphabet Challenge
+
+
+ A twist on the classic Alphabet Challenge — instead of airports, you need to fly with
+ airlines whose IATA codes begin with each letter of the alphabet.
+
+
+ You can copy your results as BBCode using the copy icon above the table, and select
+ a year to highlight airlines that are new for that given year.
+
+
+
+
+
+
+ Progress
+
+
+
+ IATA
+ ICAO
+
+
+ Show 0–9
+
+
+
+ {{ visitedLetterCount }} / {{allLetters.length}} letters visited
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Requirements
+
+ To complete this challenge you must fly with airlines whose IATA codes begin with each
+ of the 26 letters of the alphabet — A through Z. Airlines without an IATA code do not count.
+
+
+ A single flight contributes one letter — the operating airline's IATA code. So a flight
+ on QF (Qantas) counts toward Q .
+
+
+ ICAO codes do not count towards this challenge, but you can toggle to ICAO view to see
+ where you're at for fun. Similarly, numeric IATA codes (such as 3U ) exist
+ but do not count towards the challenge — enable "Show 0–9" to see them.
+
+
+ It would unlikely to be possible to complete the challenge with both numbers and letters required.
+
+
+
+
+
+ Difficulty
+
+ Like the airport version, this challenge is very difficult, but it is slightly easier and most letters have a fair few options.
+ Some difficulty.
+
+
+ X — Very few airlines; worth researching regional carriers in your area.
+
+
+ Most other letters have a reasonable selection of options across different continents,
+ though some will require deliberate routing to achieve.
+
+
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.airport_alphabet.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.airport_alphabet.vue
new file mode 100644
index 0000000..d65c3f3
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/fun_challenges.airport_alphabet.vue
@@ -0,0 +1,280 @@
+
+
+
+
+
+ The Alphabet Challenge
+
+
+ Originating from aviation forums, as Land at the Alphabet , this challenge is a tad more lenient
+ as it allows for both landing and taking off to count towards a letter.
+
+
+ In honor of this challenge's forum roots, you can copy your results of this challenge as BBCode by pressing the copy icon above the table, and select
+ a year to highlight airports that are new for that given year.
+
+
+
+
+
+
+
+ Progress
+
+ IATA
+ ICAO
+
+ {{ visitedCount }} / {{ allLetters.length }} letters visited
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Requirements
+
+ To complete this challenge you must take off from or land at airports whose
+ IATA codes begin with each of the 26 letters of the alphabet —
+ A through Z. Airports without an IATA code do not count.
+
+
+ Both the departure and arrival airport on a single flight can count toward different
+ letters simultaneously, so a flight from ABX to BNE
+ would count toward both A and B.
+
+
+ ICAO codes do not count towards this challenge (and it is not possible to visit an ICAO code for every letter), but you can
+ toggle to ICAO view to see where you're at for fun.
+
+
+
+
+
+ Difficulty
+
+ This is a very difficult challenge no matter how well travelled you are. However it one of the most fun to try and accomplish as it will get you travelling
+ to places you might never have otherwise considered.
+
+
+ Most letters are possible to visit somewhat organically if you fly enough, but some will be difficult. Q is probably the most difficult letter to obtain -
+ there's no rules against having a code starting with Q, but due to many aviation terms starting with Q (such as QNH), very few airports use it.
+
+
+ At time of writing, there are just 2 major airports that have daily scheduled service by major airlines, and 4 airports with any commercial service at all.
+ Others - such as seaplane ports - might have GA or charter service.
+
+
+ QRO in Mexico - a fantastic place to visit and with flights from around Mexico and the US.
+ QSZ in China is also a very interesting place to visit and is served well from Urumqi and Xi'an.
+ QBC is a small airport in Canada and has daily runs on commuter aircraft from Vancouver.
+ QSR is probably most promising recently - it did not have commercial service for 10 years and is having good growth now with European low cost carriers.
+
+
+ Other potential candidates do not seem so promising
+
+
+ QAH was in India as a secondary Delhi airport, but the code was changed to HDO
+ QAJ was proposed for Ajman Airport which might have acted as an alternate to Dubai and Sharjah, but construction has not moved forward.
+
+
+ Every other letter usually has a decent selection of options across most continents.
+
+
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.australian_states.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.australian_states.vue
index 32c499b..478e670 100644
--- a/resources/js/Pages/Profile/Achievements/fun_challenges.australian_states.vue
+++ b/resources/js/Pages/Profile/Achievements/fun_challenges.australian_states.vue
@@ -36,9 +36,9 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
The Challenge
-
+
Either take off or land from an airport in each Australian State and Internal Territory.
-
+
Progress
@@ -46,7 +46,7 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
Flights By State
- 5 Most Recent Flights By State
+
Requirements
- The 8 states and territories you need to visit to complete this challenge are Queensland, Victoria, New South Wales, South Australia, Tasmania, Western Australia, the Northern Territory
+ The 8 states and territories you need to visit to complete this challenge are Queensland, Victoria, New South Wales, South Australia, Tasmania, Western Australia, the Northern Territory
and the Australian Capital Territory.
diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.brazilian_states.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.brazilian_states.vue
new file mode 100644
index 0000000..fade1c9
--- /dev/null
+++ b/resources/js/Pages/Profile/Achievements/fun_challenges.brazilian_states.vue
@@ -0,0 +1,67 @@
+
+
+
+
+ The Challenge
+
+ Either take off or land from an airport in each Brazilian State plus the Distrito Federal (Brasilia).
+
+
+
+ Progress
+
+
+
+ Flights By State
+
+
+
+ Difficulty
+
+ This is a very difficult challenge - Brazil is a reasonably mature aviation market by South American standards, but flights can still be expensive and the major
+ hubs tend to be in the south so you can expect a lot of doubling back. Brasilia might not be a bad base as it's quite central and well connected, but if you're trying
+ to achieve this trophy organically, it's unlikely to just happen!
+
+
+
+
+
diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.canadian_provinces.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.canadian_provinces.vue
index b3130ef..dcb1ff7 100644
--- a/resources/js/Pages/Profile/Achievements/fun_challenges.canadian_provinces.vue
+++ b/resources/js/Pages/Profile/Achievements/fun_challenges.canadian_provinces.vue
@@ -49,8 +49,7 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
- Flights By State
- 5 Most Recent Flights By State
+ Flights By Province
Difficulty
- This challenge is not extremely difficult bur Canada's geography does complicate it and it could get expensive!
+ This challenge is not extremely difficult but Canada's geography does complicate it and it could get expensive!
Canada's population is all in the South, so visiting the Northwest Territories, Yukon and Nanavut will likely require doubling back south to get connecting flights.
diff --git a/resources/js/Pages/Profile/Achievements/fun_challenges.chinese_provinces.vue b/resources/js/Pages/Profile/Achievements/fun_challenges.chinese_provinces.vue
index ef762ca..72bc926 100644
--- a/resources/js/Pages/Profile/Achievements/fun_challenges.chinese_provinces.vue
+++ b/resources/js/Pages/Profile/Achievements/fun_challenges.chinese_provinces.vue
@@ -47,7 +47,6 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
Flights By Region
- 5 Most Recent Flights Per Region
Flights By State
- 5 Most Recent Flights By State
-import {Achievement, Region, User, UserAchievement} from "@/Types/types";
+import {Achievement, Airline, Continent, Region, User, UserAchievement} from "@/Types/types";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import {computed, defineAsyncComponent} from 'vue';
@@ -11,6 +11,7 @@ import MainLayout from "@/Layouts/MainLayout.vue";
import {useFlights} from "@/Composables/useFlights";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
+import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
defineOptions({ layout: MainLayout })
@@ -18,13 +19,18 @@ const props = defineProps<{
achievement: Achievement
userAchievement: UserAchievement
user: User
+ loggedInUser: User | null
isFollowing: boolean
flight_api_url: string
regions: Region[]
+ alliance: string | null
+ airlines: Airline[]
+ continents: Continent[]
+ aircraft_families: Record
}>()
-const { flights, flightsLoading } = useFlights(props.flight_api_url)
+const { flights, flightsLoading } = useFlights(props.flight_api_url, true)
const AchievementDetail = defineAsyncComponent(
() => import(`./Achievements/${props.achievement.internal_name}.vue`)
@@ -63,14 +69,14 @@ const difficultyVariant = computed(() => {
-
-
- Back to {{ user.name }}'s Achievements
-
-
+
+
+
+
+ You are viewing {{user.name}}'s progress in this achievement. If you would like to see your progress,
+ please click here.
+
+
@@ -110,7 +116,18 @@ const difficultyVariant = computed(() => {
-
+
diff --git a/resources/js/Pages/UserFlight.vue b/resources/js/Pages/UserFlight.vue
index eea726e..6ba2dff 100644
--- a/resources/js/Pages/UserFlight.vue
+++ b/resources/js/Pages/UserFlight.vue
@@ -1,6 +1,6 @@