Compare commits

...
54 Commits
Author SHA1 Message Date
dredgy f31f1bc2fc Add airport search filter 2026-08-22 15:00:55 +10:00
dredgy 91a6873b64 Merge pull request 'Add historic airports to database' (#20) from add-historic-airports into main
Reviewed-on: #20
2026-08-22 14:14:05 +10:00
dredgy 4a9b1ad56a Add historic airports to database 2026-08-22 14:13:37 +10:00
dredgy ad61508b3c Merge pull request 'Add a flight arc on the map for flights with same departing and arrival airport' (#19) from add-same-point-flights into main
Reviewed-on: #19
2026-08-22 13:16:10 +10:00
dredgy 95ed0e339f Add a flight arc on the map for flights with same departing and arrival airport 2026-08-22 13:15:39 +10:00
dredgy 4102dd29ef Merge pull request 'Add a flight arc on the map for flights with same departing and arrival airport' (#18) from add-same-point-flights into main
Reviewed-on: #18
2026-08-22 13:11:34 +10:00
dredgy 28fd782a40 Add a flight arc on the map for flights with same departing and arrival airport 2026-08-22 13:11:03 +10:00
dredgy 3c66a3945c Merge pull request 'Fix achievement category ordering' (#17) from fix-achievement-category-ordering into main
Reviewed-on: #17
2026-08-21 23:23:24 +10:00
dredgy a68e31dad7 Merge remote-tracking branch 'origin/main' into fix-achievement-category-ordering 2026-08-21 23:22:56 +10:00
dredgy 023ce6f964 Sort achievement categories 2026-08-21 23:22:42 +10:00
dredgy c66c734868 Merge pull request 'Ui fixes' (#16) from ui-fixes into main
Reviewed-on: #16
2026-08-21 22:54:13 +10:00
dredgy 94a7bc2807 Added missing data tooltips 2026-08-21 22:53:29 +10:00
dredgy 4a296705a6 Added admin controls for missing airlines 2026-08-21 22:41:29 +10:00
dredgy a6bf1c09fe Added "Save Flight And Add Another Button" 2026-08-21 22:17:53 +10:00
dredgy ef73dc0bc3 Added note on flight details page 2026-08-21 22:03:10 +10:00
dredgy b395de6719 Fixed column width on mobile and added a note column 2026-08-21 21:59:04 +10:00
dredgy 3b4ded2fab Fixed notification menu on mobile 2026-08-21 21:45:52 +10:00
dredgy 5492cc72d7 Updated arrival date input on flight form 2026-08-21 21:40:02 +10:00
dredgy 76118d0bfd Set default view to Profile 2026-08-21 20:52:06 +10:00
dredgy c0b8d10add Make progressive achievements get notifications when complete 2026-08-21 17:02:12 +10:00
dredgy 3ae1e75275 Merge branch 'make-flighttooltip-better-on-mobile' into main 2026-08-20 16:47:07 +10:00
dredgy 1ef4ae4a21 Merge remote-tracking branch 'origin/main' into make-flighttooltip-better-on-mobile 2026-08-20 16:46:44 +10:00
dredgy 90ea8d81d2 Allow "Log 1st Flight" achievement to be awarded even if flight is upcoming 2026-08-20 16:21:14 +10:00
dredgy 8514d76724 Merge pull request 'Tooltip on Mobile' (#12) from make-flighttooltip-better-on-mobile into main
Reviewed-on: #12
2026-08-16 20:48:02 +10:00
dredgy 7bca9a8d31 Tooltip on Mobile 2026-08-16 20:47:20 +10:00
dredgy e17203aefc Merge pull request 'Formatting and Share Buttons' (#11) from form-validation-improvements into main
Reviewed-on: #11
2026-08-15 21:05:22 +10:00
dredgy 02521da9c3 Merge remote-tracking branch 'origin/main' into form-validation-improvements
# Conflicts:
#	resources/js/Components/FlightsGoneBy/MainHeader.vue
2026-08-15 21:05:07 +10:00
dredgy ed0ce97548 Formatting and Share Buttons 2026-08-15 21:02:02 +10:00
dredgy 1030a14094 Merge pull request 'Add London Airport and Million Mile Challenges' (#10) from add-london-airport-challenge into main
Reviewed-on: #10
2026-08-11 20:53:13 +10:00
dredgy 2b41c3ed44 Add London Airport and Million Mile Challenges 2026-08-11 20:52:21 +10:00
dredgy 65e8c7575e Allowed filters in the url 2026-08-11 20:04:11 +10:00
dredgy 9d175a1525 Allowed filters in the url 2026-08-11 16:11:31 +10:00
dredgy c602393c95 Adds a user search box 2026-08-09 20:08:49 +10:00
dredgy 8832028304 Following Select Box Shows only When User Is Following People 2026-07-22 21:46:02 +10:00
dredgy 7cecdb6bde Added engine count/type filters 2026-07-22 21:25:54 +10:00
dredgy 39c1f78b20 Compact map padding improved 2026-07-22 19:08:27 +10:00
dredgy 923d9a2c3e Added user select box to achievement pages 2026-07-21 21:03:46 +10:00
dredgy 3abd66001b Fixed Chart Axes 2026-07-20 20:34:43 +10:00
dredgy fe642300b6 Bug fix for individual alliance challenges 2026-07-20 15:59:15 +10:00
dredgy 693290095d Merge pull request 'Added icons for non-achievement notifications' (#9) from add-alliance-challenge into main
Reviewed-on: #9
2026-07-20 14:54:02 +10:00
dredgy f63b54733b Added icons for non-achievement notifications 2026-07-20 14:49:05 +10:00
dredgy 866b45b01d Merge pull request 'Add alliance challenge' (#8) from add-alliance-challenge into main
Reviewed-on: #8
2026-07-20 14:43:48 +10:00
dredgy 45b4b49b83 Merge pull request 'Added concentric donut charts and added an alliance breakdown' (#7) from update-donut-charts into main
Reviewed-on: #7
2026-07-20 14:43:30 +10:00
dredgy b3047e9571 Added a challenge for flying the 3 major airline alliances 2026-07-20 14:39:37 +10:00
dredgy 267fd6fd12 Added a challenge for flying the 3 major airline alliances 2026-07-20 14:33:32 +10:00
dredgy 8464fa11d4 Added concentric donut charts and added an alliance breakdown 2026-07-20 13:38:50 +10:00
dredgy 60a3bce3bf Merge pull request 'Allow custom date time formats' (#6) from allow-custom-date-time-formats into main
Reviewed-on: #6
2026-07-18 00:47:41 +10:00
dredgy 39c0ce1b44 Added custom date format 2026-07-18 00:47:14 +10:00
dredgy 792cb082fd Merge remote-tracking branch 'origin/main' into allow-custom-date-time-formats 2026-07-18 00:46:47 +10:00
dredgy ae77fec0da Merge pull request 'Compact map padding amended' (#5) from fix-map-padding into main
Reviewed-on: #5
2026-07-18 00:46:37 +10:00
dredgy dbb3dac7f6 Fixed Bug With Shared Flights 2026-07-17 23:40:44 +10:00
dredgy 731a6aa09a Merge pull request 'Add missing airline check' (#4) from add-missing-airline-check into main
Reviewed-on: #4
2026-07-15 11:36:47 +10:00
dredgy dc476fedd6 Merge remote-tracking branch 'origin/main' into add-missing-airline-check 2026-07-15 11:36:07 +10:00
dredgy fd3b5b0405 Add preliminary missing airline check 2026-07-12 01:42:57 +10:00
76 changed files with 3285 additions and 684 deletions
@@ -17,7 +17,10 @@ class PopulateAirportTimezones extends Command
*/ */
public function handle() public function handle()
{ {
Airport::whereNull('timezone')->chunkById(100, function ($airports) { Airport::where(function ($query) {
$query->whereNull('timezone')
->orWhere('timezone', 'Undefined');
})->chunkById(100, function ($airports) {
foreach ($airports as $airport) { foreach ($airports as $airport) {
$zoneName = null; $zoneName = null;
$attempts = 0; $attempts = 0;
@@ -3,19 +3,35 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\User; use App\Models\User;
use Illuminate\Console\Attributes\Argument;
use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command; use Illuminate\Console\Command;
#[Signature('app:recalculate-achivements')] #[Signature('app:recalculate-achievements {username? : The username of a single user to recalculate}')]
#[Description('Recalculate achievements for all users')] #[Description('Recalculate achievements for all users, or a single user by username')]
class RecalculateAchievements extends Command class RecalculateAchievements extends Command
{ {
public function handle(): void public function handle(): void
{ {
$users = User::all(); $username = $this->argument('username');
foreach ($users as $user) {
if ($username) {
$user = User::where('name', $username)->first();
if (! $user) {
$this->error("No user found with username [{$username}].");
return;
}
$user->calculateAchievements(); $user->calculateAchievements();
$this->info("Recalculated achievements for {$username}.");
return;
} }
$users = User::all();
$this->withProgressBar($users, fn (User $user) => $user->calculateAchievements());
$this->newLine();
$this->info("Recalculated achievements for {$users->count()} users.");
} }
} }
+1 -1
View File
@@ -28,7 +28,7 @@ enum FlightNotificationType: string
{ {
return match ($this) { return match ($this) {
self::Booked => "{$user->name} booked a new flight", self::Booked => "{$user->name} booked a new flight",
self::Logged => "{$user->name} logged a new flight", self::Logged => "{$user->name} logged a historic flight",
self::Cancelled => "{$user->name} cancelled a flight", self::Cancelled => "{$user->name} cancelled a flight",
self::Departed => "{$user->name} is taking off!", self::Departed => "{$user->name} is taking off!",
self::Arrived => "{$user->name} has landed!", self::Arrived => "{$user->name} has landed!",
+25
View File
@@ -7,6 +7,7 @@ use App\Models\IgnoredMissingLivery;
use App\Models\User; use App\Models\User;
use App\Models\UserFlight; use App\Models\UserFlight;
use App\Services\AdminService; use App\Services\AdminService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Inertia\Inertia; use Inertia\Inertia;
@@ -19,6 +20,7 @@ class AdminController extends Controller
function staticAdminData(){ function staticAdminData(){
return [ return [
'missingLiveryCount' => $this->adminService->getMissingLiveries()->count(), 'missingLiveryCount' => $this->adminService->getMissingLiveries()->count(),
'missingAirlineCount' => $this->adminService->getMissingAirlines()->count(),
]; ];
} }
@@ -44,6 +46,16 @@ class AdminController extends Controller
]); ]);
} }
function reconcileMissingAirlines(){
$missingAirlines = $this->adminService->getMissingAirlines()->values();
return Inertia::render('Admin/MissingAirlines', [
'title' => $missingAirlines->count() . ' Missing Airlines',
'missingAirlines' => $missingAirlines,
...$this->staticAdminData(),
]);
}
public function ignoreMissingLivery(Request $request) public function ignoreMissingLivery(Request $request)
{ {
$validated = $request->validate([ $validated = $request->validate([
@@ -55,4 +67,17 @@ class AdminController extends Controller
return back(); return back();
} }
public function ignoreMissingAirline(Request $request): JsonResponse
{
$validated = $request->validate([
'flight_id' => ['required', 'integer', 'exists:user_flights,id'],
]);
$flight = UserFlight::findOrFail($validated['flight_id']);
$flight->ignoreMissingAirline()->firstOrCreate([]);
return response()->json(['status' => 'ok']);
}
} }
+8 -4
View File
@@ -245,15 +245,19 @@ class FlightController extends Controller
$newFlight = auth()->user()->flights()->create($this->flightPayload($validated)); $newFlight = auth()->user()->flights()->create($this->flightPayload($validated));
UserAction::create([ UserAction::create([
'user_id' => $newFlight->user_id, 'user_id' => $newFlight->user_id,
'type' => $newFlight->departure_date->isFuture() ? 'flight_booked' : 'flight_logged', 'type' => $newFlight->departure_date->isFuture() ? 'flight_booked' : 'flight_logged',
'data' => [ 'data' => [
'flight' => $newFlight->snapshot($newFlight->id), 'flight' => $newFlight->snapshot($newFlight->id),
], ],
]); ]);
if ($request->boolean('add_more')) {
return redirect()->route('flights.add')
->with('success', 'Flight saved. Add another below.');
}
return redirect()->route('profile.departure-board', [Auth::user()->name, $newFlight->id]); return redirect()->route('profile.flight', [Auth::user()->name, $newFlight->id]);
} }
+51 -9
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Aircraft; use App\Models\Aircraft;
use App\Models\Airline; use App\Models\Airline;
use App\Models\Airport; use App\Models\Airport;
use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class SearchController extends Controller class SearchController extends Controller
@@ -57,28 +58,69 @@ class SearchController extends Controller
{ {
$q = request('q', ''); $q = request('q', '');
$len = strlen($q); $len = strlen($q);
$showClosed = request()->boolean('show_closed');
$showNoCode = request()->boolean('show_no_code');
if ($len < 3) return []; if ($len < 3) return [];
return Airport::with('region.country') return Airport::with('region.country')
->when(!$showClosed, fn($query) => $query->where('active', true))
->when(!$showNoCode, fn($query) => $query->where(function ($sub) {
$sub->whereNotNull('iata_code')
->orWhereNotNull('icao_code');
}))
->when($len === 3, fn($query) => $query->where('iata_code', 'ilike', $q)) ->when($len === 3, fn($query) => $query->where('iata_code', 'ilike', $q))
->when($len >= 4, fn($query) => $query->where(function ($sub) use ($q, $len) { ->when($len >= 4, fn($query) => $query->where(function ($sub) use ($q, $len) {
$sub->when($len === 4, fn($s) => $s->where('icao_code', 'ilike', $q)) $sub->when($len === 4, fn($s) => $s->where('icao_code', 'ilike', $q))
->orWhere('name', 'ilike', "%{$q}%") ->orWhere('name', 'ilike', "%{$q}%")
->orWhere('municipality', 'ilike', "%{$q}%"); ->orWhere('municipality', 'ilike', "%{$q}%");
})->orderByRaw(" }))
CASE ->orderByRaw("
WHEN icao_code = ? THEN 0 CASE
WHEN iata_code = ? THEN 1 WHEN active THEN 0
ELSE 2 ELSE 1
END END
", [$q, $q])) ")
->orderByRaw("
CASE
WHEN iata_code IS NOT NULL AND icao_code IS NOT NULL THEN 0
WHEN iata_code IS NOT NULL OR icao_code IS NOT NULL THEN 1
ELSE 2
END
")
->orderByRaw("
CASE
WHEN icao_code = ? THEN 0
WHEN iata_code = ? THEN 1
ELSE 2
END
", [$q, $q])
->limit(15) ->limit(15)
->get(['id', 'name', 'municipality', 'iata_code', 'icao_code', 'region_id']) ->get(['id', 'name', 'municipality', 'iata_code', 'icao_code', 'region_id', 'active'])
->map(fn($airport) => [ ->map(fn(Airport $airport) => [
'value' => $airport->id, 'value' => $airport->id,
'title' => $airport->display_name, 'title' => $airport->display_name,
'country_code' => strtolower($airport->region->country->code), 'country_code' => strtolower($airport->region->country->code),
'active' => $airport->active,
])
->values();
}
public function users()
{
$q = request('q', '');
if (strlen($q) < 2) return [];
return User::where('name', 'ilike', "%{$q}%")
->orderBy('name')
->limit(50)
->get(['id', 'name'])
->map(fn($user) => [
'value' => $user->id,
'title' => $user->name,
]) ])
->values(); ->values();
} }
+23 -2
View File
@@ -3,7 +3,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Achievement; use App\Models\Achievement;
use App\Models\AchievementCategory;
use App\Models\Aircraft; use App\Models\Aircraft;
use App\Models\Airport;
use App\Models\Alliance; use App\Models\Alliance;
use App\Models\Continent; use App\Models\Continent;
use App\Models\Country; use App\Models\Country;
@@ -102,9 +104,16 @@ class UserProfileController extends Controller
$canView = Gate::allows('viewProfileData', $user); $canView = Gate::allows('viewProfileData', $user);
$achievements = Achievement::with(['category', 'difficulty']) $achievements = Achievement::with(['category', 'difficulty'])
->orderBy('id', 'asc')
->get() ->get()
->groupBy(fn(Achievement $a) => $a->category->name) ->groupBy(fn(Achievement $a) => $a->category->internal_name)
->map(fn($group) => $group->sortBy('sort_order')->values()); ->sortBy(function ($group, $internalName) {
$index = array_search($internalName, AchievementCategory::ORDER);
return $index === false ? PHP_INT_MAX : $index;
})
->mapWithKeys(fn($group) => [
$group->first()->category->name => $group->sortBy('sort_order')->values(),
]);
$userAchievements = $user->achievements() $userAchievements = $user->achievements()
->with('achievement') ->with('achievement')
@@ -171,6 +180,11 @@ class UserProfileController extends Controller
default => null, default => null,
}; };
$majorAlliances = match($achievement->internal_name){
'airlines_alliances.all_major_alliances' => Alliance::MAJOR_ALLIANCES,
default => [],
};
$continents = match($achievement->internal_name){ $continents = match($achievement->internal_name){
'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(), 'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(),
default => [], default => [],
@@ -193,6 +207,11 @@ class UserProfileController extends Controller
default => [], default => [],
}; };
$londonAirports = match($achievement->internal_name){
'fun_challenges.all_london_airports' => Airport::LONDON_AIRPORTS,
default => [],
};
$canView = Gate::allows('viewProfileData', $user); $canView = Gate::allows('viewProfileData', $user);
return Inertia::render('Profile/UserAchievement', [ return Inertia::render('Profile/UserAchievement', [
@@ -206,7 +225,9 @@ class UserProfileController extends Controller
'airlines' => $airlines, 'airlines' => $airlines,
'continents' => $continents, 'continents' => $continents,
'aircraft_families' => $aircraftFamilies, 'aircraft_families' => $aircraftFamilies,
'london_airports' => $londonAirports,
'achievementCount' => $user->unlockedAchievements()->count(), 'achievementCount' => $user->unlockedAchievements()->count(),
'majorAlliances' => Alliance::whereIn('internal_name', $majorAlliances)->get(),
'canView' => $canView, 'canView' => $canView,
]); ]);
} }
@@ -38,6 +38,7 @@ class HandleInertiaRequests extends Middleware
'roles' => $request->user()?->getRoleNames() ?? [], 'roles' => $request->user()?->getRoleNames() ?? [],
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [], 'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
'apiToken' => session('api_token'), 'apiToken' => session('api_token'),
'following' => $request->user()?->following()->with('followee')->get()->pluck('followee')->filter()->pluck('name')->toArray() ?? [],
], ],
'flash' => [ 'flash' => [
'success' => $request->session()->get('success'), 'success' => $request->session()->get('success'),
+10
View File
@@ -16,6 +16,16 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
*/ */
class AchievementCategory extends Model class AchievementCategory extends Model
{ {
const array ORDER = [
'number_of_flights',
'distance',
'general_flying',
'countries_and_continents',
'aircraft',
'airlines_and_alliances',
'fun_challenges',
];
protected $fillable = [ protected $fillable = [
'internal_name', 'internal_name',
'name', 'name',
+13
View File
@@ -20,12 +20,14 @@ class Airport extends Model
'icao_code', 'icao_code',
'iata_code', 'iata_code',
'local_code', 'local_code',
'active',
]; ];
protected $casts = [ protected $casts = [
'latitude_deg' => 'float', 'latitude_deg' => 'float',
'longitude_deg' => 'float', 'longitude_deg' => 'float',
'elevation_ft' => 'integer', 'elevation_ft' => 'integer',
'active' => 'boolean',
]; ];
protected $appends = [ protected $appends = [
@@ -33,6 +35,12 @@ class Airport extends Model
'display_name', 'display_name',
]; ];
const array LONDON_AIRPORTS = [
'LHR', 'LGW', 'STN', 'LTN', 'LCY', 'SEN',
];
protected function displayName() : Attribute{ protected function displayName() : Attribute{
return Attribute::make( return Attribute::make(
get: function () { get: function () {
@@ -49,6 +57,11 @@ class Airport extends Model
); );
} }
public function scopeActive($query)
{
return $query->where('active', true);
}
public function region(): BelongsTo public function region(): BelongsTo
{ {
return $this->belongsTo(Region::class); return $this->belongsTo(Region::class);
+3
View File
@@ -26,6 +26,9 @@ class Alliance extends Model
'skyteam' => 'airlines_alliances.all_skyteam', 'skyteam' => 'airlines_alliances.all_skyteam',
]; ];
const array MAJOR_ALLIANCES = ['skyteam', 'oneworld', 'star_alliance'];
public function airlines(): HasMany public function airlines(): HasMany
{ {
return $this->hasMany(Airline::class); return $this->hasMany(Airline::class);
+20 -1
View File
@@ -153,7 +153,26 @@ class User extends Authenticatable implements MustVerifyEmail
->toJson(); ->toJson();
}); });
$collection = collect(json_decode($json)); $dateFormat = auth()->user()?->resolved_settings['date_format'] ?? 'j M Y';
$timeFormat = auth()->user()?->resolved_settings['time_format'] === '24hr' ? 'Hi' : 'g:iA';
$collection = collect(json_decode($json))
->map(function ($flight) use ($dateFormat, $timeFormat) {
$departure = Carbon::parse($flight->departure_date)
->setTimezone($flight->departure_airport->timezone);
$arrival = Carbon::parse($flight->arrival_date)
->setTimezone($flight->arrival_airport->timezone);
$flight->departure_date_display = $departure->format($dateFormat);
$flight->departure_time_display = $departure->format($timeFormat);
$flight->arrival_date_display = $arrival->format($dateFormat);
$flight->arrival_time_display = $arrival->format($timeFormat);
return $flight;
});
$today = now('UTC')->toDateString(); $today = now('UTC')->toDateString();
return match ($filter) { return match ($filter) {
+11
View File
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -271,6 +272,16 @@ class UserFlight extends Model
} }
public function ignoreMissingAirline(): HasOne
{
return $this->hasOne(UserFlightIgnoreMissingAirline::class);
}
public function isIgnoringMissingAirline(): bool
{
return $this->ignoreMissingAirline()->exists();
}
public function aircraft(): BelongsTo public function aircraft(): BelongsTo
{ {
return $this->belongsTo(Aircraft::class); return $this->belongsTo(Aircraft::class);
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserFlightIgnoreMissingAirline extends Model
{
protected $table = 'user_flights_ignore_missing_airlines';
protected $fillable = [
'user_flight_id',
];
public function userFlight(): BelongsTo
{
return $this->belongsTo(UserFlight::class);
}
}
@@ -12,6 +12,7 @@ use App\Services\Achievements\Checkers\AchievementCheckerInterface;
use App\Services\Achievements\Checkers\AircraftChecker; use App\Services\Achievements\Checkers\AircraftChecker;
use App\Services\Achievements\Checkers\CountriesAndContinentsChecker; use App\Services\Achievements\Checkers\CountriesAndContinentsChecker;
use App\Services\Achievements\Checkers\AirlinesAndAlliancesChecker; use App\Services\Achievements\Checkers\AirlinesAndAlliancesChecker;
use App\Services\Achievements\Checkers\DistanceChecker;
use App\Services\Achievements\Checkers\FunChallengesChecker; use App\Services\Achievements\Checkers\FunChallengesChecker;
use App\Services\Achievements\Checkers\GeneralFlyingChecker; use App\Services\Achievements\Checkers\GeneralFlyingChecker;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -30,6 +31,7 @@ class AchievementService
AircraftChecker::class, AircraftChecker::class,
AirlinesAndAlliancesChecker::class, AirlinesAndAlliancesChecker::class,
FunChallengesChecker::class, FunChallengesChecker::class,
DistanceChecker::class,
]; ];
public function __construct() public function __construct()
@@ -45,6 +47,7 @@ class AchievementService
* @var Collection<int,UserFlight> $flights * @var Collection<int,UserFlight> $flights
*/ */
private Collection $flights; private Collection $flights;
private int $loggedFlightCount;
public function calculate(User $user): void public function calculate(User $user): void
{ {
@@ -62,20 +65,24 @@ class AchievementService
'arrivalAirport.region.continent', 'arrivalAirport.region.continent',
])->where('departure_date', '<=', now('UTC'))->get(); ])->where('departure_date', '<=', now('UTC'))->get();
$this->loggedFlightCount = $user->flights()->count();
foreach ($this->checkers as $checkerClass) { foreach ($this->checkers as $checkerClass) {
$checker = new $checkerClass($this); $checker = new $checkerClass($this);
$checker->check($user); $checker->check($user);
} }
} }
/**
* @return Collection<int,UserFlight>
*/
public function getFlights(): Collection public function getFlights(): Collection
{ {
return $this->flights; return $this->flights;
} }
public function getLoggedFlightCount(): int
{
return $this->loggedFlightCount;
}
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Award / revoke // Award / revoke
@@ -91,7 +98,8 @@ class AchievementService
->where('achievement_id', $achievement->id) ->where('achievement_id', $achievement->id)
->first(); ->first();
$alreadyUnlocked = $existing !== null; $alreadyUnlocked = $existing !== null
&& (! $achievement->progressive || $existing->progress >= $achievement->threshold);
UserAchievement::updateOrCreate( UserAchievement::updateOrCreate(
['user_id' => $user->id, 'achievement_id' => $achievement->id], ['user_id' => $user->id, 'achievement_id' => $achievement->id],
@@ -28,6 +28,12 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$check = fn(string $alliance): int => $flownAllianceAirlines->get($alliance, 0); $check = fn(string $alliance): int => $flownAllianceAirlines->get($alliance, 0);
$majorAlliancesFlown = collect(Alliance::MAJOR_ALLIANCES)
->filter(fn(string $alliance) => $check($alliance) > 0)
->count();
$this->awardProgress($majorAlliancesFlown, 'airlines_alliances.all_major_alliances');
$this->awardProgress($check('skyteam'), 'airlines_alliances.all_skyteam'); $this->awardProgress($check('skyteam'), 'airlines_alliances.all_skyteam');
$this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld'); $this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld');
$this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance'); $this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance');
@@ -40,6 +46,8 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count(); $checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count();
$this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3'); $this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3');
$this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3'); $this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3');
$this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3'); $this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3');
@@ -15,6 +15,11 @@ abstract class BaseChecker implements AchievementCheckerInterface
return $this->service->getFlights(); return $this->service->getFlights();
} }
protected function allLoggedFlightsCount(): int
{
return $this->service->getLoggedFlightCount();
}
/** /**
* Resolve an achievement ID from its internal name. * Resolve an achievement ID from its internal name.
* Results are cached on the service so repeated lookups are free. * Results are cached on the service so repeated lookups are free.
@@ -0,0 +1,26 @@
<?php
namespace App\Services\Achievements\Checkers;
use App\Models\User;
use App\Models\UserFlight;
use Illuminate\Database\Eloquent\Collection;
class DistanceChecker extends BaseChecker
{
public function check(): void
{
/**
* @var $flights Collection<int, UserFlight>
*/
$flights = $this->flights();
$totalDistance = $flights->sum('distance');
$this->awardProgress((int) $totalDistance, 'distance.circumference_of_the_earth');
$this->awardProgress((int) $totalDistance, 'distance.to_the_moon');
$this->awardProgress((int) $totalDistance, 'distance.gigametre');
$this->awardProgress((int) $totalDistance, 'distance.million_miles');
}
}
@@ -2,6 +2,7 @@
namespace App\Services\Achievements\Checkers; namespace App\Services\Achievements\Checkers;
use App\Models\Airport;
use App\Models\User; use App\Models\User;
use App\Models\UserFlight; use App\Models\UserFlight;
@@ -131,5 +132,19 @@ class FunChallengesChecker extends BaseChecker
->count(); ->count();
$this->awardProgress($visitedCanadianProvinces, 'fun_challenges.canadian_provinces'); $this->awardProgress($visitedCanadianProvinces, 'fun_challenges.canadian_provinces');
// --- London Airports ---
$visitedLondonAirports = $flights
->flatMap(fn(UserFlight $f) => [
$f->departureAirport?->iata_code,
$f->arrivalAirport?->iata_code,
])
->filter()
->map(fn($code) => strtoupper($code))
->filter(fn($code) => in_array($code, Airport::LONDON_AIRPORTS))
->unique()
->count();
$this->awardProgress($visitedLondonAirports, 'fun_challenges.all_london_airports');
} }
} }
@@ -15,10 +15,11 @@ class GeneralFlyingChecker extends BaseChecker
*/ */
$flights = $this->flights(); $flights = $this->flights();
$count = $flights->count(); $count = $flights->count();
$allLoggedFlightsCount = $this->allLoggedFlightsCount();
// --- Boolean achievements --- // --- Boolean achievements ---
$this->awardIf($count >= 1, 'general_flying.first_flight'); $this->awardIf($allLoggedFlightsCount >= 1, 'general_flying.first_flight');
$this->awardIf( $this->awardIf(
$flights->contains(fn ($f) => $f->isDomestic()), $flights->contains(fn ($f) => $f->isDomestic()),
@@ -68,14 +69,6 @@ class GeneralFlyingChecker extends BaseChecker
'general_flying.domestic_two_countries' 'general_flying.domestic_two_countries'
); );
// --- 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((int) $totalDistance, 'general_flying.gigametre');
$this->awardProgress($count,'general_flying.10_flights'); $this->awardProgress($count,'general_flying.10_flights');
$this->awardProgress($count,'general_flying.50_flights'); $this->awardProgress($count,'general_flying.50_flights');
$this->awardProgress($count,'general_flying.100_flights'); $this->awardProgress($count,'general_flying.100_flights');
+35
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\DTOs\MissingLivery; use App\DTOs\MissingLivery;
use App\Models\IgnoredMissingLivery; use App\Models\IgnoredMissingLivery;
use App\Models\UserFlight; use App\Models\UserFlight;
use Illuminate\Support\Enumerable;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -45,4 +46,38 @@ class AdminService
->sortBy('airline_name') ->sortBy('airline_name')
->values(); ->values();
} }
function getMissingAirlines(): Enumerable|Collection
{
$flights = UserFlight::whereNotNull('flight_number')
->whereNull('airline_id')
->whereDoesntHave('ignoreMissingAirline')
->with('user:id,name')
->get(['id', 'flight_number', 'user_id']);
return $flights
->map(function ($flight) {
preg_match('/^([A-Za-z0-9]{2})\d+/', trim($flight->flight_number), $matches);
return [
'id' => $flight->id,
'code' => $matches[1] ?? null,
'flight_number' => $flight->flight_number,
'link' => "/u/{$flight->user->name}/flight/{$flight->id}",
];
})
->filter(fn ($f) => $f['code'] !== null)
->groupBy('code')
->map(function ($group, $code) {
return [
'code' => $code,
'flights' => $group->map(fn ($f) => [
'id' => $f['id'],
'flight_number' => $f['flight_number'],
'link' => $f['link'],
])->values()->all(),
];
})
->values();
}
} }
+39 -14
View File
@@ -13,24 +13,13 @@ class SettingsRegistry
'AI Generated Content' => 'Airline tail logos and liveries are AI generated with human cleanup. If you would rather not see any AI, then our blank aircraft templates are human created.', 'AI Generated Content' => 'Airline tail logos and liveries are AI generated with human cleanup. If you would rather not see any AI, then our blank aircraft templates are human created.',
'Account & Privacy' => 'Everything to do with your account.', 'Account & Privacy' => 'Everything to do with your account.',
'Notifications' => 'What you want to hear about (or not!)', 'Notifications' => 'What you want to hear about (or not!)',
'Localization' => 'Units of measurement, date and time preferences.',
]; ];
} }
public static function schema(): array public static function schema(): array
{ {
return [ return [
[
'key' => 'distance_unit',
'type' => 'select',
'label' => 'Distance Units',
'category' => 'FlightsGoneBy Settings',
'default' => 'km',
'options' => [
['value' => 'km', 'label' => 'Kilometres (km)'],
['value' => 'mi', 'label' => 'Miles (mi)'],
['value' => 'nm', 'label' => 'Nautical miles (nm)'],
],
],
[ [
'category' => 'Account & Privacy', 'category' => 'Account & Privacy',
'key' => 'profile_privacy', 'key' => 'profile_privacy',
@@ -42,12 +31,48 @@ class SettingsRegistry
['value' => 'private', 'label' => 'Private Profile Viewable Only By You and Your Approved Followers'], ['value' => 'private', 'label' => 'Private Profile Viewable Only By You and Your Approved Followers'],
] ]
], ],
[
'key' => 'distance_unit',
'type' => 'select',
'label' => 'Distance Units',
'category' => 'Localization',
'default' => 'km',
'options' => [
['value' => 'km', 'label' => 'Kilometres (km)'],
['value' => 'mi', 'label' => 'Miles (mi)'],
['value' => 'nm', 'label' => 'Nautical miles (nm)'],
],
],
[
'key' => 'time_format',
'type' => 'select',
'label' => 'Time Format',
'category' => 'Localization',
'default' => 'ampm',
'options' => [
['value' => 'ampm', 'label' => 'AM/PM'],
['value' => '24hr', 'label' => '24-Hour'],
],
],
[
'key' => 'date_format',
'type' => 'select',
'label' => 'Date Format',
'category' => 'Localization',
'default' => 'j M Y',
'options' => [
['value' => 'j M Y', 'label' => '20 Feb 2015'],
['value' => 'Y-m-d', 'label' => '2015-02-20'],
['value' => 'd/m/Y', 'label' => '20/02/2015'],
['value' => 'm/d/Y', 'label' => '02/20/2015'],
],
],
[ [
'key' => 'default_login_page', 'key' => 'default_login_page',
'type' => 'select', 'type' => 'select',
'label' => 'Default Page', 'label' => 'Default Page',
'category' => 'FlightsGoneBy Settings', 'category' => 'FlightsGoneBy Settings',
'default' => 'feed_first', 'default' => 'profile',
'options' => [ 'options' => [
['value' => 'feed_first', 'label' => 'My Feed if Following People, My Profile if Not'], ['value' => 'feed_first', 'label' => 'My Feed if Following People, My Profile if Not'],
['value' => 'profile', 'label' => 'My Profile'], ['value' => 'profile', 'label' => 'My Profile'],
@@ -130,7 +155,7 @@ class SettingsRegistry
['value' => 'seat_type_id', 'label' => 'Seat Type'], ['value' => 'seat_type_id', 'label' => 'Seat Type'],
['value' => 'seat_number', 'label' => 'Seat Number'], ['value' => 'seat_number', 'label' => 'Seat Number'],
['value' => 'flight_reason_id', 'label' => 'Reason'], ['value' => 'flight_reason_id', 'label' => 'Reason'],
['value' => 'note', 'label' => 'Note'],
['value' => 'aircraft.display_name_short', 'label' => 'Aircraft Full Name'], ['value' => 'aircraft.display_name_short', 'label' => 'Aircraft Full Name'],
['value' => 'class_seat_combined', 'label' => 'Class/Seat Type/Seat Number Combined'], ['value' => 'class_seat_combined', 'label' => 'Class/Seat Type/Seat Number Combined'],
], ],
+1 -1
View File
@@ -67,7 +67,7 @@ return Application::configure(basePath: dirname(__DIR__))
'message' => 'You don\'t have permission to access this page.', 'message' => 'You don\'t have permission to access this page.',
], ],
404 => [ 404 => [
'title' => 'You Flight Has Been Cancelled', 'title' => 'Your Flight Has Been Cancelled',
'message' => 'The page you are looking for doesn\'t exist or has been moved.', 'message' => 'The page you are looking for doesn\'t exist or has been moved.',
], ],
419 => [ 419 => [
@@ -0,0 +1,58 @@
<?php
use App\Models\Achievement;
use App\Models\AchievementCategory;
use App\Models\AchievementDifficulty;
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.
*/
public function up(): void
{
$moderate = AchievementDifficulty::where('internal_name', 'moderate')->first();
$airlines_and_alliances = AchievementCategory::where('internal_name', 'airlines_and_alliances')->first();
Achievement::create([
'name' => 'Allied Forces',
'internal_name' => 'airlines_alliances.all_major_alliances',
'short_description' => 'Fly The 3 Major Airline Alliances (Skyteam, Star Alliance, Oneworld)',
'progressive' => true,
'difficulty_description' => 'For most people in most parts of the world this is quite achievable',
'threshold' => 3,
'achievement_category_id' => $airlines_and_alliances->id,
'achievement_difficulty_id' => $moderate->id,
'has_page' => true,
'sort_order' => 1,
'icon' => 'standard_achievement.png',
'long_description' => '',
]);
Achievement::where('internal_name', 'airlines_alliances.cn3')->update(['sort_order' => 3]);
Achievement::where('internal_name', 'airlines_alliances.us3')->update(['sort_order' => 1]);
Achievement::where('internal_name', 'airlines_alliances.me3')->update(['sort_order' => 2]);
Achievement::where('internal_name', 'airlines_alliances.all_major_alliances')->update(['sort_order' => 4]);
Achievement::where('internal_name', 'airlines_alliances.all_skyteam')->update(['sort_order' => 5]);
Achievement::where('internal_name', 'airlines_alliances.all_oneworld')->update(['sort_order' => 6]);
Achievement::where('internal_name', 'airlines_alliances.all_star_alliance')->update(['sort_order' => 7]);
Achievement::where('internal_name', 'airlines_alliances.all_vanilla_alliance')->update(['sort_order' => 8]);
foreach(User::all() as $user){
$user->calculateAchievements();
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};
@@ -0,0 +1,87 @@
<?php
use App\Models\Achievement;
use App\Models\AchievementCategory;
use App\Models\AchievementDifficulty;
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.
*/
public function up(): void
{
$hard = AchievementDifficulty::where('internal_name', 'hard')->first();
$near_impossible = AchievementDifficulty::where('internal_name', 'near_impossible')->first();
$fun_challenges = AchievementCategory::where('internal_name', 'fun_challenges')->first();
Achievement::create([
'name' => 'London Calling',
'internal_name' => 'fun_challenges.all_london_airports',
'short_description' => 'Fly to all official London airports',
'progressive' => true,
'difficulty_description' => 'Much more achievable if you live in London!',
'threshold' => 6,
'achievement_category_id' => $fun_challenges->id,
'achievement_difficulty_id' => $hard->id,
'has_page' => true,
'sort_order' => 10,
'icon' => 'standard_achievement.png',
'long_description' => '',
]);
$distance = AchievementCategory::create([
'name' => 'Distance',
'internal_name' => 'distance',
'description' => 'Achievements that are based on distance'
]);
Achievement::create([
'name' => 'Million Miler',
'internal_name' => 'distance.million_miles',
'short_description' => 'Fly one million miles',
'progressive' => true,
'threshold' => 1609344,
'achievement_category_id' => $distance->id,
'achievement_difficulty_id' => $near_impossible->id,
'has_page' => false,
'sort_order' => 4,
'icon' => 'standard_achievement.png',
'long_description' => '',
]);
Achievement::whereInternalName('general_flying.gigametre')->update([
'internal_name' => 'distance.gigametre',
'achievement_category_id' => $distance->id,
'sort_order' => 3
]);
Achievement::whereInternalName('general_flying.circumference_of_the_earth')->update([
'internal_name' => 'distance.circumference_of_the_earth',
'achievement_category_id' => $distance->id,
'sort_order' => 1
]);
Achievement::whereInternalName('general_flying.to_the_moon')->update([
'internal_name' => 'distance.to_the_moon',
'achievement_category_id' => $distance->id,
'sort_order' => 2
]);
foreach(User::all() as $user){
$user->calculateAchievements();
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_flights_ignore_missing_airlines', function (Blueprint $table) {
$table->id();
$table->foreignId('user_flight_id')
->constrained('user_flights')
->cascadeOnDelete();
$table->timestamps();
$table->unique('user_flight_id');
});
}
public function down(): void
{
Schema::dropIfExists('user_flights_ignore_missing_airlines');
}
};
@@ -0,0 +1,57 @@
<?php
use App\Models\Achievement;
use App\Models\AchievementCategory;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
$newCategory = AchievementCategory::create([
'internal_name' => 'number_of_flights',
'name' => 'Number of Flights',
'description' => 'Achievements for reaching flight count milestones.',
]);
$achievementsToUpdate = [
'general_flying.first_flight',
'general_flying.10_flights',
'general_flying.500_flights',
'general_flying.50_flights',
'general_flying.100_flights',
'general_flying.1000_flights',
];
foreach ($achievementsToUpdate as $internalName) {
$achievement = Achievement::where('internal_name', $internalName)->first();
if (!$achievement) {
continue; // or throw, depending how strict you want this
}
$achievement->update([
'achievement_category_id' => $newCategory->id,
'internal_name' => str_replace('general_flying.', 'number_of_flights.', $internalName),
]);
}
}
public function down(): void
{
$oldCategory = AchievementCategory::where('internal_name', 'general_flying')->first();
if ($oldCategory) {
Achievement::where('internal_name', 'like', 'number_of_flights.%')
->get()
->each(function (Achievement $achievement) use ($oldCategory) {
$achievement->update([
'achievement_category_id' => $oldCategory->id,
'internal_name' => str_replace('number_of_flights.', 'general_flying.', $achievement->internal_name),
]);
});
}
AchievementCategory::where('internal_name', 'number_of_flights')->delete();
}
};
@@ -0,0 +1,133 @@
<?php
use App\Models\Airport;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('airports', function (Blueprint $table) {
$table->boolean('active')->default(true);
});
Airport::whereIataCode('TXL')->update(['active' => false]);
$this->importCsv();
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('airports', function (Blueprint $table) {
$table->dropColumn('active');
});
}
private function importCsv(): void
{
$path = storage_path('app/private/seed_data/airports.csv');
if (! file_exists($path)) {
throw new \RuntimeException("Airports CSV not found at: {$path}");
}
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new \RuntimeException("Failed to open airports CSV at: {$path}");
}
// Skip header row
fgetcsv($handle);
$regionMap = DB::table('regions')->pluck('id', 'code')->all();
$batch = [];
$batchSize = 500;
$now = now()->toDateTimeString();
while (($row = fgetcsv($handle)) !== false) {
if (count($row) < 19) {
continue;
}
// id, ident, type, name, latitude_deg, longitude_deg, elevation_ft,
// continent, iso_country, iso_region, municipality, scheduled_service,
// icao_code, iata_code, gps_code, local_code, home_link, wikipedia_link, keywords
[
,
,
$type,
$name,
$latitudeDeg,
$longitudeDeg,
$elevationFt,
,
,
$isoRegion,
$municipality,
,
$icaoCode,
$iataCode,
,
$localCode,
] = $row;
$icaoCode = trim(str_replace(["\r", "\n"], '', $icaoCode));
$iataCode = trim(str_replace(["\r", "\n"], '', $iataCode));
// Only importing airports with no IATA/ICAO code — coded airports are already in the DB
if ($icaoCode !== '' || $iataCode !== '') {
continue;
}
$type = trim($type);
$name = trim($name);
$localCode = trim($localCode);
$isoRegion = trim($isoRegion);
if (! isset($regionMap[$isoRegion])) {
continue;
}
$elevationFt = trim($elevationFt);
$batch[] = [
'type' => $type,
'name' => $name,
'latitude_deg' => (float) trim($latitudeDeg),
'longitude_deg' => (float) trim($longitudeDeg),
'elevation_ft' => $elevationFt !== '' ? (int) $elevationFt : null,
'region_id' => $regionMap[$isoRegion],
'municipality' => trim($municipality) !== '' ? trim($municipality) : null,
'icao_code' => null,
'iata_code' => null,
'local_code' => $localCode !== '' ? $localCode : null,
'timezone' => 'Undefined',
'active' => $type !== 'closed',
'created_at' => $now,
'updated_at' => $now,
];
echo "Added: {$name}\n";
if (count($batch) >= $batchSize) {
DB::table('airports')->insert($batch);
$batch = [];
}
}
fclose($handle);
if (! empty($batch)) {
DB::table('airports')->insert($batch);
}
}
};
+1 -1
View File
@@ -8,7 +8,7 @@
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5", "apexcharts": "^5.10.5",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"echarts": "^6.0.0", "echarts": "^6.1.0",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"maplibre-gl": "^5.22.0", "maplibre-gl": "^5.22.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
+1 -1
View File
@@ -41,7 +41,7 @@
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5", "apexcharts": "^5.10.5",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"echarts": "^6.0.0", "echarts": "^6.1.0",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"maplibre-gl": "^5.22.0", "maplibre-gl": "^5.22.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref, computed } from "vue"
import axios from "axios"
import { MissingAirline } from "@/Types/types"
const props = defineProps<{
airline: MissingAirline
}>()
// Track ids we've dismissed locally (optimistic UI) and ones currently in-flight
const dismissedIds = ref<Set<number>>(new Set())
const pendingIds = ref<Set<number>>(new Set())
const visibleFlights = computed(() =>
props.airline.flights.filter(flight => !dismissedIds.value.has(flight.id))
)
async function dismissFlight(flightId: number) {
if (pendingIds.value.has(flightId)) return
pendingIds.value.add(flightId)
// Optimistically remove the pill
dismissedIds.value.add(flightId)
try {
await axios.post('/admin/ignore-missing-airline', {
flight_id: flightId,
})
} catch (error) {
// Revert on failure so the flight isn't silently lost from view
dismissedIds.value.delete(flightId)
console.error('Failed to ignore missing airline for flight', flightId, error)
} finally {
pendingIds.value.delete(flightId)
}
}
</script>
<template>
<div class="missing-airline-card">
<div class="card-header">
<span class="airline-code">{{ airline.code }}</span>
<span class="flight-count">{{ visibleFlights.length }} flight{{ visibleFlights.length === 1 ? '' : 's' }}</span>
</div>
<div class="flight-list">
<v-chip
v-for="flight in visibleFlights"
:key="flight.link"
class="flight-chip"
size="small"
closable
:disabled="pendingIds.has(flight.id)"
@click:close="dismissFlight(flight.id)"
>
<a :href="flight.link" class="flight-link" target="_blank" rel="noopener" @click.stop>
{{ flight.flight_number }}
</a>
</v-chip>
</div>
</div>
</template>
<style scoped>
.missing-airline-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding-bottom: 0.5rem;
}
.airline-code {
font-family: 'Share Tech Mono', monospace;
font-size: 1.1rem;
letter-spacing: 0.08em;
color: #ffc107;
font-weight: 700;
}
.flight-count {
font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #778;
}
.flight-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.flight-chip {
font-family: 'Share Tech Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.06em;
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.flight-link {
color: #9fb3c8;
text-decoration: none;
transition: color 0.15s ease;
}
.flight-link:hover {
color: #ffc107;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick } from 'vue' import { ref, nextTick, watch } from 'vue'
import axios from 'axios' import axios from 'axios'
const props = defineProps<{ const props = defineProps<{
@@ -12,8 +12,14 @@ const model = defineModel<{ value: number, title: string, country_code: string }
const airportOptions = ref(props.prefilledOptions ?? []) const airportOptions = ref(props.prefilledOptions ?? [])
const autocompleteRef = ref<any>(null) const autocompleteRef = ref<any>(null)
const showClosed = ref(false)
const showNoCode = ref(false)
const lastQuery = ref('')
const menuOpen = ref(false)
const onFocus = () => { const onFocus = () => {
menuOpen.value = true
nextTick(() => { nextTick(() => {
const input = autocompleteRef.value?.$el?.querySelector('input') const input = autocompleteRef.value?.$el?.querySelector('input')
input?.select() input?.select()
@@ -21,6 +27,8 @@ const onFocus = () => {
} }
const searchAirports = async (query: string) => { const searchAirports = async (query: string) => {
lastQuery.value = query
if (!query || query.length < 2) { if (!query || query.length < 2) {
airportOptions.value = props.prefilledOptions ?? [] airportOptions.value = props.prefilledOptions ?? []
return return
@@ -28,15 +36,27 @@ const searchAirports = async (query: string) => {
if (query === model.value?.title) return if (query === model.value?.title) return
const { data } = await axios.get('/search/airports', { params: { q: query } }) const { data } = await axios.get('/search/airports', {
params: {
q: query,
show_closed: showClosed.value ? 1 : undefined,
show_no_code: showNoCode.value ? 1 : undefined,
},
})
airportOptions.value = data airportOptions.value = data
} }
// Re-run the last search when either checkbox is toggled, so results update immediately
watch([showClosed, showNoCode], () => {
if (lastQuery.value) searchAirports(lastQuery.value)
})
</script> </script>
<template> <template>
<v-autocomplete <v-autocomplete
ref="autocompleteRef" ref="autocompleteRef"
v-model="model" v-model="model"
v-model:menu="menuOpen"
:label="label ?? 'Airport'" :label="label ?? 'Airport'"
:items="airportOptions" :items="airportOptions"
:error-messages="errorMessages" :error-messages="errorMessages"
@@ -48,6 +68,7 @@ const searchAirports = async (query: string) => {
clearable clearable
hide-no-data hide-no-data
return-object return-object
autocomplete="off"
:custom-filter="() => true" :custom-filter="() => true"
> >
<template #prepend-inner> <template #prepend-inner>
@@ -55,6 +76,27 @@ const searchAirports = async (query: string) => {
<span v-if="model" :class="`fi fi-${model.country_code}`"></span> <span v-if="model" :class="`fi fi-${model.country_code}`"></span>
</span> </span>
</template> </template>
<template #prepend-item>
<div class="px-4 py-1 d-flex align-center" style="gap: 16px;" @mousedown.prevent>
<v-checkbox
v-model="showClosed"
label="Show Closed Airports"
density="compact"
hide-details
style="flex: none; transform: scale(0.65); transform-origin: left center;"
/>
<v-checkbox
v-model="showNoCode"
label="Show Airports with No Code"
density="compact"
hide-details
style="flex: none; transform: scale(0.65); transform-origin: left center;"
/>
</div>
<v-divider class="mt-1" />
</template>
<template #item="{ item, props: itemProps }"> <template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps"> <v-list-item v-bind="itemProps">
<template #prepend> <template #prepend>
@@ -62,7 +104,6 @@ const searchAirports = async (query: string) => {
<span :class="`fi fi-${item.country_code}`"></span> <span :class="`fi fi-${item.country_code}`"></span>
</span> </span>
</template> </template>
</v-list-item> </v-list-item>
</template> </template>
</v-autocomplete> </v-autocomplete>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue";
import {FlightStats} from "@/Composables/useFlightStats";
const props = defineProps<{
flightStats: FlightStats
}>()
</script>
<template>
<DonutChart
title="Alliances"
:height="280"
:labels="flightStats.alliances.value.labels"
:series="flightStats.alliances.value.series"
total-label="Alliances"
center-mode="count"
/>
</template>
<style scoped>
</style>
@@ -1,80 +1,283 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { ChartType } from '@/Types/types' import { use } from 'echarts/core'
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue" import { CanvasRenderer } from 'echarts/renderers'
import VueApexCharts from "vue3-apexcharts" import { PieChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent])
const chartRef = ref<InstanceType<typeof VChart>>()
onMounted(() => {
// vue-echarts' autoresize can measure the container before the page's
// own layout (flex/grid) has settled, leaving the chart off-center until
// something else triggers a resize. Force one right after mount.
nextTick(() => chartRef.value?.resize())
})
type SeriesEntry = { name: string; data: number[] }
interface RingConfig {
name: string
series: number[]
colors: string[]
}
const props = defineProps<{ const props = defineProps<{
title: string title: string
height: number | string height: number | string
series: number[]
labels: string[] labels: string[]
// Either a flat array (single ring, e.g. Flight Types) or an array of
// named series (multi ring, e.g. Continents: [{name:'Flights',data},{name:'Upcoming',data}])
series: number[] | SeriesEntry[]
total?: number total?: number
options?: object totalLabel?: string
// 'sum' (default): center shows the total of all values (e.g. 308 flights).
// 'count': center shows how many categories have a non-zero value (e.g. 6
// continents) — for charts where summing the slices double-counts things,
// like a flight touching two continents.
centerMode?: 'sum' | 'count'
}>() }>()
const chartOptions = computed(() => ({ // Same color for the same label across every ring (matches the legend),
chart: { // distinguished visually by which ring they're in rather than by shade.
type: 'donut', const PALETTE = ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4', 'pink']
background: 'transparent',
fontFamily: 'inherit',
animations: { enabled: false },
},
theme: { mode: 'dark' },
labels: props.labels,
colors: ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4', 'pink'],
dataLabels: {
enabled: true,
formatter: (val: number) => `${Math.round(val)}%`,
style: { fontSize: '12px', fontWeight: 400 },
dropShadow: { enabled: false },
},
plotOptions: {
pie: {
donut: {
size: '60%',
labels: {
show: true,
total: {
show: true,
label: 'Total',
fontSize: '13px',
color: '#556677',
formatter: () => (props.total ?? props.series.reduce((a, b) => a + b, 0)).toString(),
},
value: {
fontSize: '22px',
fontWeight: 500,
color: '#e0e6f0',
},
},
},
},
},
legend: {
position: 'bottom',
labels: { colors: '#778899' },
markers: { width: 8, height: 8, radius: 2 },
itemMargin: { horizontal: 12, vertical: 4 },
},
stroke: { width: 0 },
tooltip: {
theme: 'dark',
y: { formatter: (val: number) => `${val} flights` },
},
}))
// Build rings from `series`, dropping any ring that's entirely zero
// (e.g. a continent with no upcoming flights at all -> no "Upcoming" ring).
const normalizedRings = computed<RingConfig[]>(() => {
const raw = props.series
if (!raw || !raw.length) return []
if (typeof raw[0] === 'number') {
return [{ name: props.title, series: raw as number[], colors: PALETTE }]
}
return (raw as SeriesEntry[])
.map((entry) => ({ name: entry.name, series: entry.data, colors: PALETTE }))
.filter((ring) => ring.series.some((v) => v > 0))
})
const isMulti = computed(() => normalizedRings.value.length > 1)
const mode = ref<'all' | string>('all')
const modeOptions = computed(() => [
{ key: 'all', label: normalizedRings.value.length === 2 ? 'Both' : 'All' },
...normalizedRings.value.map((r) => ({ key: r.name, label: `${r.name} only` })),
])
// Skip zero-value slices entirely, rather than drawing an invisible sliver with a stray label/border.
function toSeriesData(ring: RingConfig) {
return ring.series
.map((value, i) => ({
name: props.labels[i] ?? `Item ${i + 1}`,
value,
itemStyle: { color: ring.colors[i % ring.colors.length] },
}))
.filter((d) => d.value > 0)
}
function ringCount(ring: RingConfig) {
return ring.series.filter((v) => v > 0).length
}
function ringSum(ring: RingConfig) {
return ring.series.reduce((a, b) => a + b, 0)
}
const centerLabel = computed(() => {
const aggregate = props.centerMode === 'count' ? ringCount : ringSum
if (mode.value !== 'all') {
const ring = normalizedRings.value.find((r) => r.name === mode.value)!
return { title: ring.name, value: aggregate(ring) }
}
if (props.centerMode === 'count') {
// Distinct categories with a non-zero value in ANY ring (not summed
// across rings, since a continent visited on both legs is still one continent).
const present = new Set<string>()
normalizedRings.value.forEach((ring) => {
ring.series.forEach((value, i) => {
if (value > 0) present.add(props.labels[i] ?? `Item ${i + 1}`)
})
})
return { title: props.totalLabel ?? 'Total', value: present.size }
}
return {
title: props.totalLabel ?? 'Total',
value: props.total ?? normalizedRings.value.reduce((sum, r) => sum + ringSum(r), 0),
}
})
const activeRings = computed(() =>
mode.value === 'all' ? normalizedRings.value : normalizedRings.value.filter((r) => r.name === mode.value)
)
// Same radius bands as the original working sample:
// - isolated (single ring, or one toggled on its own): 55%80%
// - nested pair: outer 68%88%, inner 32%62% (leaves a roomy center hole)
// - 3+ rings: evenly spaced bands, falling back to a generous hole
function ringRadius(i: number, n: number, isolated: boolean): [number, number] {
if (isolated) return [55, 80]
if (n === 2) return i === 0 ? [68, 88] : [42, 62]
const edge = 88
const hole = 30
const gap = 6
const bandWidth = (edge - hole - gap * (n - 1)) / n
const outer = edge - i * (bandWidth + gap)
const inner = outer - bandWidth
return [inner, outer]
}
const numericHeight = computed(() => (typeof props.height === 'number' ? props.height : parseInt(String(props.height), 10) || 280))
interface SliceLabel {
key: string
x: number
y: number
text: string
}
// Computed independently of ECharts' own label placement (see chartOption below
// for why): for each visible slice, find its mid-angle and the ring's
// mid-radius, then convert to a pixel offset from the chart's center using
// plain trig. ECharts pies start at 90° (12 o'clock) and sweep clockwise,
// which in standard (counter-clockwise-positive) math angle terms means the
// angle decreases as cumulative value increases.
const sliceLabels = computed<SliceLabel[]>(() => {
const n = normalizedRings.value.length
const isolated = mode.value !== 'all' || n === 1
const chartRadiusPx = numericHeight.value / 2
const labels: SliceLabel[] = []
activeRings.value.forEach((ring) => {
const i = normalizedRings.value.findIndex((r) => r.name === ring.name)
const [innerPct, outerPct] = ringRadius(i, n, isolated)
const midRadiusPx = ((innerPct + outerPct) / 2 / 100) * chartRadiusPx
const total = ring.series.reduce((a, b) => a + b, 0)
if (!total) return
let cumulative = 0
ring.series.forEach((value, idx) => {
const percent = (value / total) * 100
const midFraction = (cumulative + value / 2) / total
cumulative += value
if (value <= 0 || percent < 5) return
const angleDeg = 90 - midFraction * 360
const angleRad = (angleDeg * Math.PI) / 180
labels.push({
key: `${ring.name}-${props.labels[idx] ?? idx}`,
x: midRadiusPx * Math.cos(angleRad),
y: -midRadiusPx * Math.sin(angleRad),
text: `${Math.round(percent)}%`,
})
})
})
return labels
})
const chartOption = computed(() => {
const n = normalizedRings.value.length
const isolated = mode.value !== 'all' || n === 1
const series = activeRings.value.map((ring) => {
const i = normalizedRings.value.findIndex((r) => r.name === ring.name)
const [innerPct, outerPct] = ringRadius(i, n, isolated)
return {
name: ring.name,
type: 'pie',
radius: [`${innerPct}%`, `${outerPct}%`],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderColor: 'transparent', borderWidth: 0 },
// ECharts' own label placement (both 'inside' and 'outside' + negative
// distance) proved unreliable here, so labels are rendered separately
// as plain positioned HTML — see `sliceLabels` below.
label: { show: false },
labelLine: { show: false },
data: toSeriesData(ring),
}
})
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: '#0f1b33',
borderColor: '#2a3a5c',
textStyle: { color: '#e0e6f0', fontSize: 12 },
formatter: (p: any) =>
`<b>${p.seriesName} · ${p.name}</b><br/>${p.value} flights (${Math.round(p.percent)}%)`,
},
series,
}
})
const legendItems = computed(() => {
const first = normalizedRings.value[0]
if (!first) return []
return props.labels.map((label, i) => ({ label, color: first.colors[i % first.colors.length] }))
})
</script> </script>
<template> <template>
<div class="chart-wrap"> <div class="chart-wrap">
<div class="chart-title">{{ title }}</div> <div class="chart-title">{{ title }}</div>
<VueApexCharts
type="donut" <template v-if="isMulti">
:height="height" <div class="toggle-row">
:options="options ?? chartOptions" <button
:series="series" v-for="opt in modeOptions"
/> :key="opt.key"
class="toggle-btn"
:class="{ active: mode === opt.key }"
@click="mode = opt.key"
>
{{ opt.label }}
</button>
</div>
<div v-if="mode === 'all'" class="ring-labels">
<span v-for="(r, i) in normalizedRings" :key="r.name">
{{ i === 0 ? 'Outer' : i === normalizedRings.length - 1 ? 'Inner' : `Ring ${i + 1}` }}
Ring = {{ r.name }}
</span>
</div>
</template>
<div class="donut-container" :style="{ height: typeof height === 'number' ? `${height}px` : height }">
<VChart ref="chartRef" class="chart" :option="chartOption" autoresize />
<div class="center-label">
<div class="center-label-title">{{ centerLabel.title }}</div>
<div class="center-label-value">{{ centerLabel.value }}</div>
</div>
<div
v-for="lbl in sliceLabels"
:key="lbl.key"
class="slice-label"
:style="{ transform: `translate(calc(-50% + ${lbl.x}px), calc(-50% + ${lbl.y}px))` }"
>
{{ lbl.text }}
</div>
</div>
<div v-if="legendItems.length" class="legend">
<div v-for="item in legendItems" :key="item.label" class="legend-item">
<span class="dot" :style="{ background: item.color }"></span>
<span>{{ item.label }}</span>
</div>
</div>
</div> </div>
</template> </template>
@@ -82,6 +285,7 @@ const chartOptions = computed(() => ({
.chart-wrap { .chart-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
gap: 4px; gap: 4px;
} }
@@ -93,11 +297,99 @@ const chartOptions = computed(() => ({
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.chart-hidden { .donut-container {
visibility: hidden; position: relative;
width: 100%;
} }
:deep(svg) { .chart {
outline: none; width: 100%;
height: 100%;
}
.center-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
}
.center-label-title {
font-size: 13px;
color: #556677;
}
.center-label-value {
font-size: 22px;
font-weight: 500;
color: #e0e6f0;
}
.slice-label {
position: absolute;
top: 50%;
left: 50%;
color: #fff;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
}
.toggle-row {
display: flex;
justify-content: center;
gap: 8px;
margin: 4px 0 4px;
}
.toggle-btn {
background: transparent;
border: 1px solid #2a3a5c;
color: #778899;
border-radius: 20px;
padding: 5px 12px;
font-size: 11px;
cursor: pointer;
}
.toggle-btn.active {
background: #1e3a6a;
color: #e0e6f0;
border-color: #1e3a6a;
}
.ring-labels {
display: flex;
justify-content: center;
gap: 20px;
font-size: 11px;
color: #556677;
margin-bottom: 4px;
}
.legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px;
margin-top: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #778899;
}
.dot {
width: 8px;
height: 8px;
border-radius: 2px;
display: inline-block;
} }
</style> </style>
@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue"; import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue";
import {FlightStats} from "@/Composables/useFlightStats"; import { FlightStats } from "@/Composables/useFlightStats";
const props = defineProps<{ const props = defineProps<{
flightStats: FlightStats flightStats: FlightStats
}>() }>()
</script> </script>
<template> <template>
<DonutChart <DonutChart
title="Continents" title="Continents"
:height="280" :height="280"
:labels="flightStats.continents.value.labels" :labels="flightStats.continents.value.labels"
:series="flightStats.continents.value.series" :series="flightStats.continents.value.series"
total-label="Continents"
center-mode="count"
/> />
</template> </template>
<style scoped>
<style scoped>
</style> </style>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280" :height="280"
:labels="flightStats.classes.value.labels" :labels="flightStats.classes.value.labels"
:series="flightStats.classes.value.series" :series="flightStats.classes.value.series"
total-label="Flight Classes"
center-mode="count"
/> />
</template> </template>
<style scoped> <style scoped>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280" :height="280"
:labels="flightStats.reasons.value.labels" :labels="flightStats.reasons.value.labels"
:series="flightStats.reasons.value.series" :series="flightStats.reasons.value.series"
total-label="Reasons"
center-mode="count"
/> />
</template> </template>
<style scoped> <style scoped>
@@ -7,8 +7,9 @@ const props = defineProps<{
</script> </script>
<template> <template>
<DonutChart <DonutChart
title="Flight Types" title="Flight Scope"
:height="280" :height="280"
total-label="Flights"
:labels="flightStats.flightTypes.value.labels" :labels="flightStats.flightTypes.value.labels"
:series="flightStats.flightTypes.value.series" :series="flightStats.flightTypes.value.series"
/> />
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280" :height="280"
:labels="flightStats.seatTypes.value.labels" :labels="flightStats.seatTypes.value.labels"
:series="flightStats.seatTypes.value.series" :series="flightStats.seatTypes.value.series"
center-mode="count"
total-label="Seat Types"
/> />
</template> </template>
<style scoped> <style scoped>
@@ -52,7 +52,7 @@ const chartEvents = computed(() => ({
<InlineBadge v-if="index !== null" variant="generic">{{ aircraft[index].designator }}</InlineBadge> <InlineBadge v-if="index !== null" variant="generic">{{ aircraft[index].designator }}</InlineBadge>
</div> </div>
<div class="ct-row"> <div class="ct-row">
<span class="ct-label">Flights</span> <span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? aircraft[index].past : '' }}</span> <span class="ct-val">{{ index !== null ? aircraft[index].past : '' }}</span>
</div> </div>
<div v-if="index !== null && aircraft[index].upcoming" class="ct-row"> <div v-if="index !== null && aircraft[index].upcoming" class="ct-row">
@@ -52,7 +52,7 @@ const chartEvents = computed(() => ({
{{ index !== null ? airlines[index].name : '' }} {{ index !== null ? airlines[index].name : '' }}
</div> </div>
<div class="ct-row"> <div class="ct-row">
<span class="ct-label">Flights</span> <span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? airlines[index].past : '' }}</span> <span class="ct-val">{{ index !== null ? airlines[index].past : '' }}</span>
</div> </div>
<div v-if="index !== null && airlines[index].upcoming" class="ct-row"> <div v-if="index !== null && airlines[index].upcoming" class="ct-row">
@@ -37,7 +37,7 @@ const series = computed(() => props.flightStats.topRoutes.value.series)
<ChartTooltip :visible="visible" :x="x" :y="y"> <ChartTooltip :visible="visible" :x="x" :y="y">
<div class="ct-name">{{ index !== null ? routes[index].label : '' }}</div> <div class="ct-name">{{ index !== null ? routes[index].label : '' }}</div>
<div class="ct-row"> <div class="ct-row">
<span class="ct-label">Flights</span> <span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? routes[index].past : '' }}</span> <span class="ct-val">{{ index !== null ? routes[index].past : '' }}</span>
</div> </div>
<div v-if="index !== null && routes[index].upcoming" class="ct-row"> <div v-if="index !== null && routes[index].upcoming" class="ct-row">
@@ -0,0 +1,55 @@
<template>
<v-btn
:color="copied ? 'success' : color"
:variant="variant"
:icon="iconOnly"
:size="iconOnly ? 'x-small' : 'default'"
@click="handleCopy"
>
<v-icon :start="!iconOnly" :icon="copied ? 'mdi-check' : 'mdi-content-copy'" />
<template v-if="!iconOnly">{{ copied ? 'Copied Link To Page!' : label }}</template>
<v-tooltip v-if="iconOnly" activator="parent" location="top">
{{ copied ? 'Copied Link To Page!' : label }}
</v-tooltip>
</v-btn>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useDisplay } from 'vuetify';
import { copyToClipboard } from '@/Composables/useClipboard';
interface Props {
label?: string;
color?: string;
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain';
resetDelay?: number;
}
const props = withDefaults(defineProps<Props>(), {
label: 'Copy link',
color: 'primary',
variant: 'outlined',
resetDelay: 2000,
});
const { mobile } = useDisplay();
const iconOnly = mobile;
const copied = ref(false);
let resetTimeout: ReturnType<typeof setTimeout> | null = null;
async function handleCopy() {
try {
await copyToClipboard(window.location.href);
copied.value = true;
if (resetTimeout) clearTimeout(resetTimeout);
resetTimeout = setTimeout(() => {
copied.value = false;
}, props.resetDelay);
} catch (err) {
console.error('Failed to copy URL:', err);
}
}
</script>
@@ -17,7 +17,7 @@ const props = defineProps<{
const page = usePage<SharedProps>().props const page = usePage<SharedProps>().props
const ITEMS_PER_PAGE = 25 const ITEMS_PER_PAGE = 25
const defaultColumns = ['airline', 'flight_number', 'departure_airport', 'arrival_airport', 'departure_date', 'departure_time', 'arrival_time', 'duration', 'distance', 'aircraft', 'registration', 'class_seat_combined'] const defaultColumns = ['airline', 'flight_number', 'departure_airport', 'arrival_airport', 'departure_date', 'departure_time', 'arrival_time', 'duration', 'distance', 'aircraft', 'registration', 'class_seat_combined', 'note']
const columnsToShow = computed(() => page.auth.user?.resolved_settings?.departure_board_columns ?? defaultColumns) const columnsToShow = computed(() => page.auth.user?.resolved_settings?.departure_board_columns ?? defaultColumns)
const showColumn = (column: string) => columnsToShow.value.includes(column) const showColumn = (column: string) => columnsToShow.value.includes(column)
@@ -44,6 +44,7 @@ const allHeaders = [
{ title: 'SEAT TYPE', key: 'seat_type_id', sortable: true }, { title: 'SEAT TYPE', key: 'seat_type_id', sortable: true },
{ title: 'SEAT NO.', key: 'seat_number', sortable: true }, { title: 'SEAT NO.', key: 'seat_number', sortable: true },
{ title: 'REASON', key: 'flight_reason_id', sortable: true }, { title: 'REASON', key: 'flight_reason_id', sortable: true },
{ title: 'NOTE', key: 'note', sortable: true },
{ title: '', key: 'actions', sortable: false }, { title: '', key: 'actions', sortable: false },
] ]
@@ -41,7 +41,7 @@ const reasonBadgeVariant = (reason: string) => {
} }
const showColumn = (column: string) => props.columnsToShow.includes(column) const showColumn = (column: string) => props.columnsToShow.includes(column)
console.log(props.columnsToShow)
</script> </script>
<template> <template>
@@ -98,12 +98,12 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
<td v-if="showColumn('departure_time')" class="v-data-table__td"> <td v-if="showColumn('departure_time')" class="v-data-table__td">
<span class="time-cell"> <span class="time-cell">
<Mono small>{{ flight.departure_time_display }}</Mono> <Mono small>{{flight.departure_time_display}}</Mono>
</span> </span>
</td> </td>
<td v-if="showColumn('arrival_time')" class="v-data-table__td arrival-time-cell"> <td v-if="showColumn('arrival_time')" class="v-data-table__td arrival-time-cell">
<Mono small>{{ flight.arrival_time_display }}</Mono> <Mono small>{{flight.arrival_time_display}}</Mono>
<DayDifference :value="flight.arrival_day_difference" /> <DayDifference :value="flight.arrival_day_difference" />
</td> </td>
@@ -170,6 +170,10 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
</span> </span>
</td> </td>
<td v-if="showColumn('note')" class="v-data-table__td note-cell">
<span v-if="flight.note" class="note-text">{{ flight.note }}</span>
</td>
<td class="v-data-table__td actions-cell"> <td class="v-data-table__td actions-cell">
<UserFlightContextMenu :profile-user="user" :can-edit="canEdit" :flight="flight" /> <UserFlightContextMenu :profile-user="user" :can-edit="canEdit" :flight="flight" />
</td> </td>
@@ -184,6 +188,17 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
padding-right: 0 !important; padding-right: 0 !important;
} }
.note-cell {
min-width: 200px;
max-width: 320px;
}
.note-text {
display: block;
overflow: hidden;
font-size: 0.85rem;
}
.city-name{ .city-name{
color: #556; color: #556;
text-transform: uppercase; text-transform: uppercase;
@@ -211,23 +226,14 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
align-items: center; align-items: center;
vertical-align: middle; vertical-align: middle;
gap: 0.5rem; gap: 0.5rem;
overflow-x: auto; width: fit-content;
max-width: 160px;
/* Hide scrollbar but keep functionality */
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
} }
.iata { .iata {
font-weight: 600; font-weight: 600;
} }
.class-cell {
display: flex;
align-items: center;
vertical-align: middle;
gap: 0.5rem;
}
.actions-cell { .actions-cell {
width: 40px; width: 40px;
@@ -277,15 +283,5 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
padding: 0 !important; padding: 0 !important;
} }
.class-badges-cell {
max-width: 140px;
overflow: hidden;
}
.class-cell > *:not(:first-child) {
display: none;
}
} }
</style> </style>
@@ -15,6 +15,7 @@ import TopRoutesChart from '@/Components/FlightsGoneBy/Charts/TopRoutesChart.vue
import FlightTypeChart from '@/Components/FlightsGoneBy/Charts/FlightTypeChart.vue' import FlightTypeChart from '@/Components/FlightsGoneBy/Charts/FlightTypeChart.vue'
import {FlightStats} from "@/Composables/useFlightStats"; import {FlightStats} from "@/Composables/useFlightStats";
import TopAircraftChart from "@/Components/FlightsGoneBy/Charts/TopAircraftChart.vue"; import TopAircraftChart from "@/Components/FlightsGoneBy/Charts/TopAircraftChart.vue";
import AllianceChart from "@/Components/FlightsGoneBy/Charts/AllianceChart.vue";
defineProps<{ defineProps<{
flightStats: FlightStats flightStats: FlightStats
@@ -56,11 +57,14 @@ defineProps<{
<!-- Airlines + Airports --> <!-- Airlines + Airports -->
<div class="flight-charts glass charts-row"> <div class="flight-charts glass charts-row">
<TopAirlinesChart :flight-stats="flightStats" /> <TopAirlinesChart :flight-stats="flightStats" />
<TopAirportsChart :flight-stats="flightStats" /> <div class="charts-stack">
<AllianceChart :flight-stats="flightStats" />
</div>
</div> </div>
<!-- Routes + International vs Domestic --> <!-- Routes + International vs Domestic -->
<div class="flight-charts glass charts-row"> <div class="flight-charts glass charts-row">
<TopAirportsChart :flight-stats="flightStats" />
<TopRoutesChart :flight-stats="flightStats" /> <TopRoutesChart :flight-stats="flightStats" />
<TopAircraftChart :flight-stats="flightStats" /> <TopAircraftChart :flight-stats="flightStats" />
</div> </div>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Airline, Alliance, Flight, RegionRange, FlightRange, FlightScope } from '@/Types/types' import type { Airline, Alliance, Flight, RegionRange, FlightRange, FlightScope } from '@/Types/types'
import { ref, watch } from 'vue' import { reactive, ref, watch, onMounted, nextTick } from 'vue'
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue" import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue"
const props = defineProps<{ const props = defineProps<{
@@ -23,11 +23,63 @@ const emit = defineEmits<{
seatTypes: number[] seatTypes: number[]
manufacturers: string[] manufacturers: string[]
aircraftModels: number[] aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[] airportRegions: number[]
otherUsers: string[] otherUsers: string[]
}] }]
}>() }>()
// ── Filter registry ──────────────────────────────────────────────────────────
//
// This is the single source of truth for every filter's identity. To add a
// new filter:
// 1. Add one entry here (name = the key used in `filters` + the URL param,
// emitKey = the key expected by the `change` event, kind = how to parse
// values coming back out of the URL).
// 2. Add a <v-select> in the template with :name="'yourName'" and
// v-model="filters.yourName" (initialise it in `filters` below, or just
// let step 1 do it for you since `filters` is derived from this list).
//
// Nothing else needs to know about the new filter — URL loading, URL syncing,
// and the emitted payload are all generic over this list.
type FilterKind = 'number' | 'string'
interface FilterDef {
name: string // key in `filters`, and the URL query param name
emitKey: string // key in the payload sent via the `change` event
kind: FilterKind // how to coerce values parsed back out of the URL
}
const FILTER_DEFS: FilterDef[] = [
{ name: 'year', emitKey: 'years', kind: 'number' },
{ name: 'airline', emitKey: 'airlines', kind: 'number' },
{ name: 'alliance', emitKey: 'alliances', kind: 'number' },
{ name: 'country', emitKey: 'countries', kind: 'string' },
{ name: 'continent', emitKey: 'continents', kind: 'string' },
{ name: 'flightClass', emitKey: 'flightClasses', kind: 'number' },
{ name: 'crewType', emitKey: 'crewTypes', kind: 'number' },
{ name: 'flightScope', emitKey: 'flightScopes', kind: 'string' },
{ name: 'flightRange', emitKey: 'flightRanges', kind: 'string' },
{ name: 'regionRange', emitKey: 'regionRanges', kind: 'string' },
{ name: 'flightReason', emitKey: 'flightReasons', kind: 'number' },
{ name: 'seatType', emitKey: 'seatTypes', kind: 'number' },
{ name: 'manufacturer', emitKey: 'manufacturers', kind: 'string' },
{ name: 'aircraftModel', emitKey: 'aircraftModels', kind: 'number' },
{ name: 'engineType', emitKey: 'engineTypes', kind: 'string' },
{ name: 'engineCount', emitKey: 'engineCounts', kind: 'number' },
{ name: 'airportRegion', emitKey: 'airportRegions', kind: 'number' },
{ name: 'otherUsers', emitKey: 'otherUsers', kind: 'string' },
]
// Single reactive bag of filter state, one array per FILTER_DEFS entry.
// `filters.year`, `filters.airline`, etc. This is what every v-select
// v-models against.
const filters = reactive<Record<string, any[]>>(
Object.fromEntries(FILTER_DEFS.map(d => [d.name, []])),
)
// ── Available options ───────────────────────────────────────────────────────── // ── Available options ─────────────────────────────────────────────────────────
type AirlineData = { id: number; name: string; airline: Airline } type AirlineData = { id: number; name: string; airline: Airline }
@@ -62,6 +114,8 @@ function buildOptions(flights: Flight[]) {
const regionRanges = new Set<RegionRange>() const regionRanges = new Set<RegionRange>()
const manufacturers = new Map<string, { name: string }>() const manufacturers = new Map<string, { name: string }>()
const aircraftModels = new Map<number, { id: number; name: string }>() const aircraftModels = new Map<number, { id: number; name: string }>()
const engineTypes = new Map<string, { name: string }>()
const engineCounts = new Map<number, { count: number }>()
const airportRegions = new Map<number, { id: number; name: string; countryCodes: Set<string> }>() const airportRegions = new Map<number, { id: number; name: string; countryCodes: Set<string> }>()
const otherUsers = new Set<string>() const otherUsers = new Set<string>()
@@ -120,6 +174,12 @@ function buildOptions(flights: Flight[]) {
if (f.aircraft?.id != null && f.aircraft?.display_name_short) if (f.aircraft?.id != null && f.aircraft?.display_name_short)
aircraftModels.set(f.aircraft.id, { id: f.aircraft.id, name: f.aircraft.display_name_short }) aircraftModels.set(f.aircraft.id, { id: f.aircraft.id, name: f.aircraft.display_name_short })
if (f.aircraft?.engine_type)
engineTypes.set(f.aircraft.engine_type, { name: f.aircraft.engine_type })
if (f.aircraft?.engine_count != null)
engineCounts.set(f.aircraft.engine_count, { count: f.aircraft.engine_count })
flightScopes.add(f.scope) flightScopes.add(f.scope)
flightRanges.add(f.range) flightRanges.add(f.range)
regionRanges.add(f.region_range) regionRanges.add(f.region_range)
@@ -144,6 +204,8 @@ function buildOptions(flights: Flight[]) {
regionRanges: regionRangeOrder.filter(r => regionRanges.has(r)).map(r => ({ value: r, label: REGION_RANGE_LABELS[r] })), regionRanges: regionRangeOrder.filter(r => regionRanges.has(r)).map(r => ({ value: r, label: REGION_RANGE_LABELS[r] })),
manufacturers: [...manufacturers.values()].sort((a, b) => a.name.localeCompare(b.name)), manufacturers: [...manufacturers.values()].sort((a, b) => a.name.localeCompare(b.name)),
aircraftModels: [...aircraftModels.values()].sort((a, b) => a.name.localeCompare(b.name)), aircraftModels: [...aircraftModels.values()].sort((a, b) => a.name.localeCompare(b.name)),
engineTypes: [...engineTypes.values()].sort((a, b) => a.name.localeCompare(b.name)),
engineCounts: [...engineCounts.values()].sort((a, b) => a.count - b.count),
airportRegions: [...airportRegions.values()].sort((a, b) => a.name.localeCompare(b.name)), airportRegions: [...airportRegions.values()].sort((a, b) => a.name.localeCompare(b.name)),
otherUsers: [...otherUsers].sort((a, b) => a.localeCompare(b)), otherUsers: [...otherUsers].sort((a, b) => a.localeCompare(b)),
} }
@@ -151,57 +213,64 @@ function buildOptions(flights: Flight[]) {
const availableOptions = buildOptions(props.flights) const availableOptions = buildOptions(props.flights)
// ── Filter state ────────────────────────────────────────────────────────────── // ── URL query sync ──────────────────────────────────────────────────────────
const selectedYears = ref<number[]>([]) function loadFiltersFromQuery(): boolean {
const selectedAirlines = ref<number[]>([]) const params = new URLSearchParams(window.location.search)
const selectedAlliances = ref<number[]>([]) let foundAny = false
const selectedCountries = ref<string[]>([]) for (const def of FILTER_DEFS) {
const selectedContinents = ref<string[]>([]) const raw = params.get(def.name)
const selectedFlightClasses = ref<number[]>([]) if (!raw) continue
const selectedCrewTypes = ref<number[]>([]) const values = raw.split(',').filter(Boolean)
const selectedFlightScopes = ref<FlightScope[]>([]) filters[def.name] = def.kind === 'number'
const selectedFlightRanges = ref<FlightRange[]>([]) ? values.map(Number).filter(n => !Number.isNaN(n))
const selectedRegionRanges = ref<RegionRange[]>([]) : values
const selectedFlightReasons = ref<number[]>([]) foundAny = true
const selectedSeatTypes = ref<number[]>([]) }
const selectedManufacturers = ref<string[]>([]) return foundAny
const selectedAircraftModels = ref<number[]>([]) }
const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([])
// When selected countries change, drop any selected regions that no longer function syncUrlWithFilters() {
// belong to any of the selected countries. const params = new URLSearchParams()
watch(selectedCountries, (countries) => { for (const def of FILTER_DEFS) {
if (countries.length === 0) return const value = filters[def.name]
selectedAirportRegions.value = selectedAirportRegions.value.filter(regionId => { if (value.length > 0)
const region = availableOptions.airportRegions.find(r => r.id === regionId) params.set(def.name, value.join(','))
if (!region) return false }
return [...(region as any).countryCodes].some((code: string) => countries.includes(code)) const query = params.toString()
}) const newUrl = `${window.location.pathname}${query ? `?${query}` : ''}`
}) window.history.replaceState(window.history.state, '', newUrl)
}
// ── Emit ──────────────────────────────────────────────────────────────────────
function emitFilters() { function emitFilters() {
emit('change', { syncUrlWithFilters()
years: selectedYears.value, const payload = Object.fromEntries(
airlines: selectedAirlines.value, FILTER_DEFS.map(def => [def.emitKey, filters[def.name]]),
alliances: selectedAlliances.value, )
countries: selectedCountries.value, emit('change', payload as Parameters<typeof emit>[1] extends [infer T] ? T : never)
continents: selectedContinents.value,
flightClasses: selectedFlightClasses.value,
crewTypes: selectedCrewTypes.value,
flightScopes: selectedFlightScopes.value,
flightRanges: selectedFlightRanges.value,
regionRanges: selectedRegionRanges.value,
flightReasons: selectedFlightReasons.value,
seatTypes: selectedSeatTypes.value,
manufacturers: selectedManufacturers.value,
aircraftModels: selectedAircraftModels.value,
airportRegions: selectedAirportRegions.value,
otherUsers: selectedOtherUsers.value,
})
} }
onMounted(async () => {
const hasQueryFilters = loadFiltersFromQuery()
// Only emit if the URL actually seeded some filters - a plain page load
// (no query params) should behave exactly like before and not emit at all,
// since the parent already renders its own initial, unfiltered state.
if (!hasQueryFilters) return
// Wait a beat before emitting. Charts further down the tree (ApexCharts,
// in particular) initialize asynchronously on their own mount; firing our
// update in the same synchronous pass can hand them new series data before
// their internal chart instance exists yet, which throws inside the
// charting library. A couple of ticks is enough to let that settle.
await nextTick()
await nextTick()
emitFilters()
})
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const countryFlagClass = (code: string) => const countryFlagClass = (code: string) =>
@@ -209,326 +278,384 @@ const countryFlagClass = (code: string) =>
</script> </script>
<template> <template>
<div class="flight-filters"> <div class="flight-filters-wrapper">
<div class="flight-filters">
<v-select <v-select
v-model="selectedYears" name="year"
:items="availableOptions.years" v-model="filters.year"
label="Year" :items="availableOptions.years"
multiple clearable hide-details label="Year"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ item }}<span v-if="index < Math.min(selectedYears.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ item }}<span v-if="index < Math.min(filters.year.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedYears.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.year.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-model="selectedAirlines" name="airline"
:items="availableOptions.airlines" v-model="filters.airline"
item-title="name" item-value="id" :items="availableOptions.airlines"
label="Airline" item-title="name" item-value="id"
multiple clearable hide-details label="Airline"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #item="{ item, props: itemProps }"> >
<v-list-item v-bind="itemProps"> <template #item="{ item, props: itemProps }">
<template #prepend="{ isSelected }"> <v-list-item v-bind="itemProps">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" /> <template #prepend="{ isSelected }">
</template> <v-checkbox-btn :model-value="isSelected" tabindex="-1" />
<template #title> </template>
<img <template #title>
:src="(item as AirlineData).airline.logo_url" <img
width="32" height="32" :src="(item as AirlineData).airline.logo_url"
style="object-fit: contain; margin-right: 8px; vertical-align: middle;" width="32" height="32"
alt="" style="object-fit: contain; margin-right: 8px; vertical-align: middle;"
/> alt=""
{{ (item as any).name }} />
</template> {{ (item as any).name }}
</v-list-item> </template>
</template> </v-list-item>
<template #selection="{ item, index }"> </template>
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirlines.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.airline.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirlines.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.airline.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.alliances.length > 0" v-if="availableOptions.alliances.length > 0"
v-model="selectedAlliances" name="alliance"
:items="availableOptions.alliances" v-model="filters.alliance"
item-title="name" item-value="id" :items="availableOptions.alliances"
label="Alliance" item-title="name" item-value="id"
multiple clearable hide-details label="Alliance"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #item="{ item, props: itemProps }"> >
<v-list-item v-bind="itemProps"> <template #item="{ item, props: itemProps }">
<template #prepend="{ isSelected }"> <v-list-item v-bind="itemProps">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" /> <template #prepend="{ isSelected }">
</template> <v-checkbox-btn :model-value="isSelected" tabindex="-1" />
<template #title> </template>
<AllianceLogo :size="22" :alliance="(item as any).alliance" style="margin-right: 8px;" /> <template #title>
{{ (item as any).name }} <AllianceLogo :size="22" :alliance="(item as any).alliance" style="margin-right: 8px;" />
</template> {{ (item as any).name }}
</v-list-item> </template>
</template> </v-list-item>
<template #selection="{ item, index }"> </template>
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAlliances.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.alliance.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAlliances.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.alliance.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-model="selectedCountries" name="country"
:items="availableOptions.countries" v-model="filters.country"
item-title="name" item-value="code" :items="availableOptions.countries"
label="Country" item-title="name" item-value="code"
multiple clearable hide-details label="Country"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #item="{ item, props: itemProps }"> >
<v-list-item v-bind="itemProps"> <template #item="{ item, props: itemProps }">
<template #prepend="{ isSelected }"> <v-list-item v-bind="itemProps">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" /> <template #prepend="{ isSelected }">
</template> <v-checkbox-btn :model-value="isSelected" tabindex="-1" />
<template #title> </template>
<span :class="countryFlagClass((item as any).code)" style="margin-right: 8px; font-size: 1.1em;" /> <template #title>
{{ (item as any).name }} <span :class="countryFlagClass((item as any).code)" style="margin-right: 8px; font-size: 1.1em;" />
</template> {{ (item as any).name }}
</v-list-item> </template>
</template> </v-list-item>
<template #selection="{ item, index }"> </template>
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
<span :class="countryFlagClass((item as any).code)" style="margin-right: 4px;" /> <span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedCountries.length, 2) - 1">,&nbsp;</span> <span :class="countryFlagClass((item as any).code)" style="margin-right: 4px;" />
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.country.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCountries.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.country.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.flightScopes.length > 1" v-if="availableOptions.flightScopes.length > 1"
v-model="selectedFlightScopes" name="flightScope"
:items="availableOptions.flightScopes" v-model="filters.flightScope"
item-title="label" item-value="value" :items="availableOptions.flightScopes"
label="Flight Scope" item-title="label" item-value="value"
multiple clearable hide-details label="Flight Scope"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).label }}<span v-if="index < Math.min(selectedFlightScopes.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).label }}<span v-if="index < Math.min(filters.flightScope.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightScopes.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightScope.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.airportRegions.length > 0" v-if="availableOptions.airportRegions.length > 0"
v-model="selectedAirportRegions" name="airportRegion"
:items="availableOptions.airportRegions" v-model="filters.airportRegion"
item-title="name" item-value="id" :items="availableOptions.airportRegions"
label="Region" item-title="name" item-value="id"
multiple clearable hide-details label="Region"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirportRegions.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.airportRegion.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirportRegions.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.airportRegion.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.regionRanges.length > 1" v-if="availableOptions.regionRanges.length > 1"
v-model="selectedRegionRanges" name="regionRange"
:items="availableOptions.regionRanges" v-model="filters.regionRange"
item-title="label" item-value="value" :items="availableOptions.regionRanges"
label="Region Range" item-title="label" item-value="value"
multiple clearable hide-details label="Region Range"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).label }}<span v-if="index < Math.min(selectedRegionRanges.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).label }}<span v-if="index < Math.min(filters.regionRange.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedRegionRanges.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.regionRange.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-model="selectedContinents" name="continent"
:items="availableOptions.continents" v-model="filters.continent"
item-title="name" item-value="code" :items="availableOptions.continents"
label="Continent" item-title="name" item-value="code"
multiple clearable hide-details label="Continent"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedContinents.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.continent.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedContinents.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.continent.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.flightRanges.length > 1" v-if="availableOptions.flightRanges.length > 1"
v-model="selectedFlightRanges" name="flightRange"
:items="availableOptions.flightRanges" v-model="filters.flightRange"
item-title="label" item-value="value" :items="availableOptions.flightRanges"
label="Flight Range" item-title="label" item-value="value"
multiple clearable hide-details label="Flight Range"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).label }}<span v-if="index < Math.min(selectedFlightRanges.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).label }}<span v-if="index < Math.min(filters.flightRange.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightRanges.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightRange.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-model="selectedFlightClasses" name="flightClass"
:items="availableOptions.classes" v-model="filters.flightClass"
item-title="name" item-value="id" :items="availableOptions.classes"
label="Flight Class" item-title="name" item-value="id"
multiple clearable hide-details label="Flight Class"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedFlightClasses.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.flightClass.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightClasses.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightClass.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.flightReasons.length > 0" v-if="availableOptions.flightReasons.length > 0"
v-model="selectedFlightReasons" name="flightReason"
:items="availableOptions.flightReasons" v-model="filters.flightReason"
item-title="name" item-value="id" :items="availableOptions.flightReasons"
label="Flight Reason" item-title="name" item-value="id"
multiple clearable hide-details label="Flight Reason"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedFlightReasons.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.flightReason.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightReasons.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightReason.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.seatTypes.length > 0" v-if="availableOptions.seatTypes.length > 0"
v-model="selectedSeatTypes" name="seatType"
:items="availableOptions.seatTypes" v-model="filters.seatType"
item-title="name" item-value="id" :items="availableOptions.seatTypes"
label="Seat Type" item-title="name" item-value="id"
multiple clearable hide-details label="Seat Type"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedSeatTypes.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.seatType.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedSeatTypes.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.seatType.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.manufacturers.length > 0" v-if="availableOptions.manufacturers.length > 0"
v-model="selectedManufacturers" name="manufacturer"
:items="availableOptions.manufacturers" v-model="filters.manufacturer"
item-title="name" item-value="name" :items="availableOptions.manufacturers"
label="Aircraft Manufacturer" item-title="name" item-value="name"
multiple clearable hide-details label="Aircraft Manufacturer"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedManufacturers.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.manufacturer.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedManufacturers.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.manufacturer.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.aircraftModels.length > 0" v-if="availableOptions.aircraftModels.length > 0"
v-model="selectedAircraftModels" name="aircraftModel"
:items="availableOptions.aircraftModels" v-model="filters.aircraftModel"
item-title="name" item-value="id" :items="availableOptions.aircraftModels"
label="Aircraft Model" item-title="name" item-value="id"
multiple clearable hide-details label="Aircraft Model"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAircraftModels.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.aircraftModel.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAircraftModels.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.aircraftModel.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.crewTypes.length > 0" v-if="availableOptions.engineTypes.length > 0"
v-model="selectedCrewTypes" name="engineType"
:items="availableOptions.crewTypes" v-model="filters.engineType"
item-title="name" item-value="id" :items="availableOptions.engineTypes"
label="Crew Type" item-title="name" item-value="name"
multiple clearable hide-details label="Engine Type"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedCrewTypes.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.engineType.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCrewTypes.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.engineType.length - 2 }}</span>
</v-select> </template>
<v-select </v-select>
v-if="availableOptions.otherUsers.length > 0"
v-model="selectedOtherUsers" <v-select
:items="availableOptions.otherUsers" v-if="availableOptions.engineCounts.length > 0"
label="Flew With" name="engineCount"
multiple clearable hide-details v-model="filters.engineCount"
density="compact" variant="outlined" :items="availableOptions.engineCounts"
@update:model-value="emitFilters" item-title="count" item-value="count"
> label="Engine Count"
<template #selection="{ item, index }"> multiple clearable hide-details
<span v-if="index < 2" class="v-select__selection-text"> density="compact" variant="outlined"
{{ item }}<span v-if="index < Math.min(selectedOtherUsers.length, 2) - 1">,&nbsp;</span> @update:model-value="emitFilters"
</span> >
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedOtherUsers.length - 2 }}</span> <template #selection="{ item, index }">
</template> <span v-if="index < 2" class="v-select__selection-text">
</v-select> {{ (item as any).count }}<span v-if="index < Math.min(filters.engineCount.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.engineCount.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.crewTypes.length > 0"
name="crewType"
v-model="filters.crewType"
:items="availableOptions.crewTypes"
item-title="name" item-value="id"
label="Crew Type"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(filters.crewType.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.crewType.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.otherUsers.length > 0"
name="otherUsers"
v-model="filters.otherUsers"
:items="availableOptions.otherUsers"
label="Flew With"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ item }}<span v-if="index < Math.min(filters.otherUsers.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.otherUsers.length - 2 }}</span>
</template>
</v-select>
</div>
</div> </div>
</template> </template>
@@ -543,4 +670,8 @@ const countryFlagClass = (code: string) =>
.flight-filters .v-select { .flight-filters .v-select {
flex: 1 1 160px; flex: 1 1 160px;
} }
.flight-filters-actions {
margin-top: 12px;
}
</style> </style>
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div class="flight-map-wrapper"> <div class="flight-map-wrapper" :class="{ tooltip }">
<PlaneLoader v-if="!mapReady" class="map-loader" /> <PlaneLoader v-if="!mapReady" class="map-loader" />
<div ref="mapContainer" class="map-container" :class="{ 'map-hidden': !mapReady }" /> <div ref="mapContainer" class="map-container" :class="{ 'map-hidden': !mapReady }" />
<button @click="exportMapBasic()" class="export-btn" title="Download as Image"> <button @click="exportMapBasic()" class="export-btn" title="Download as Image">
@@ -277,8 +277,11 @@ export default defineComponent({
compact: { compact: {
type: Boolean, type: Boolean,
default: false, default: false,
},
tooltip: {
type: Boolean,
default: false,
} }
}, },
setup(props) { setup(props) {
@@ -367,13 +370,37 @@ export default defineComponent({
link.click() link.click()
} }
function loopPoints(origin: LngLat, radiusDeg = 0.2, steps = 48): LngLat[] {
const [lng, lat] = origin
// Widen the longitude radius to compensate for mercator distortion at this latitude
const lngRadius = radiusDeg / Math.max(Math.cos(lat * Math.PI / 180), 0.15)
// Center the loop above the origin so the airport point sits at the bottom of the loop
const centerLat = lat + radiusDeg
const points: LngLat[] = []
for (let i = 0; i <= steps; i++) {
const theta = (i / steps) * Math.PI * 2
points.push([
lng + Math.sin(theta) * lngRadius,
centerLat - radiusDeg * Math.cos(theta),
])
}
return points // first and last point === origin, at the bottom of the loop
}
const getArc = (flight: Flight): LngLat[] => { const getArc = (flight: Flight): LngLat[] => {
const key = routeKey(flight.departure_airport, flight.arrival_airport) const key = routeKey(flight.departure_airport, flight.arrival_airport)
if (!arcCache.has(key)) { if (!arcCache.has(key)) {
arcCache.set(key, greatCirclePoints( const isSameAirport = flight.departure_airport.id === flight.arrival_airport.id
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg], arcCache.set(key, isSameAirport
)) ? loopPoints([flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg])
: greatCirclePoints(
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
))
} }
return arcCache.get(key)! return arcCache.get(key)!
} }
@@ -659,10 +686,14 @@ export default defineComponent({
const fitBounds = (): void => { const fitBounds = (): void => {
if (!props.flights.length) return if (!props.flights.length) return
const padding = props.compact ? 40 : 60 const padding = props.compact
? { top: 20, bottom: 20, left: 55, right: 55 }
: { top: 60, bottom: 60, left: 60, right: 60 }
const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg]) // Use full arc/loop geometry, not just endpoints, so loops aren't clipped
const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg]) const allPoints = props.flights.flatMap(getArc)
const lngs = allPoints.map(p => p[0])
const lats = allPoints.map(p => p[1])
const minLat = Math.min(...lats) const minLat = Math.min(...lats)
const maxLat = Math.max(...lats) const maxLat = Math.max(...lats)
@@ -684,7 +715,6 @@ export default defineComponent({
} }
map!.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding, duration: 0 }) map!.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding, duration: 0 })
} }
// ── Map init ────────────────────────────────────────────────────────── // ── Map init ──────────────────────────────────────────────────────────
const initMap = (): void => { const initMap = (): void => {
@@ -819,6 +849,11 @@ export default defineComponent({
aspect-ratio: 7 / 10; aspect-ratio: 7 / 10;
max-height: none; max-height: none;
} }
.flight-map-wrapper.tooltip {
aspect-ratio: 16 / 10;
max-height: 30vh;
}
} }
small.attribution{ small.attribution{
@@ -40,7 +40,7 @@ defineProps<{
<ToolTipRows v-if="isActive.value"> <ToolTipRows v-if="isActive.value">
<FlightMap :flights="[flight]" :showLegend="false" compact /> <FlightMap :flights="[flight]" :showLegend="false" compact tooltip />
</ToolTipRows> </ToolTipRows>
<ToolTipDivider /> <ToolTipDivider />
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { usePage, router } from "@inertiajs/vue3";
import { SharedProps } from "@/Types/types";
const props = withDefaults(
defineProps<{
redirectRoute: string;
routeParams?: Record<string, string | number>;
placeholder?: string;
}>(),
{
routeParams: () => ({}),
}
);
const page = usePage<SharedProps>().props;
const following = [...page.auth?.following ?? [], page.auth?.user?.name]
.filter((x, i, a) => a.indexOf(x) === i);
function onUpdate(user: string) {
if (!user) return;
router.get(
route(props.redirectRoute, {
user,
...props.routeParams,
})
);
}
</script>
<template>
<VSelect
v-if="following.length > 1"
:placeholder="placeholder"
variant="outlined"
density="comfortable"
:items="following"
@update:model-value="onUpdate"
>
</VSelect>
</template>
<style scoped>
</style>
@@ -4,6 +4,7 @@ defineProps<{
rowKey: (row: T) => string | number rowKey: (row: T) => string | number
hasItems: (row: T) => boolean hasItems: (row: T) => boolean
labelWidth?: string labelWidth?: string
compactOnMobile?: boolean
}>() }>()
</script> </script>
@@ -15,7 +16,11 @@ defineProps<{
class="badge-row" class="badge-row"
> >
<!-- Label column: consumer provides content --> <!-- Label column: consumer provides content -->
<div class="badge-label" :style="labelWidth ? `width: ${labelWidth}` : ''"> <div
class="badge-label"
:class="{ 'badge-label--compact': compactOnMobile }"
:style="labelWidth ? `width: ${labelWidth}` : ''"
>
<slot name="label" :row="row" /> <slot name="label" :row="row" />
</div> </div>
@@ -38,8 +43,6 @@ defineProps<{
container-type: inline-size; container-type: inline-size;
} }
.badge-row { .badge-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -49,13 +52,13 @@ defineProps<{
.badge-label { .badge-label {
flex: 0 0 auto; flex: 0 0 auto;
width: 15em; width: 21em;
padding: 0.5em; padding: 0.5em;
border-right: 1px solid var(--table-border); border-right: 1px solid var(--table-border);
align-self: stretch; align-self: stretch;
display: flex; display: flex;
align-items: center; align-items: center;
font-size:0.9rem; font-size: 0.9rem;
} }
@container (max-width: 480px) { @container (max-width: 480px) {
@@ -63,6 +66,12 @@ defineProps<{
font-size: 0.75rem; font-size: 0.75rem;
width: 9em; width: 9em;
} }
.badge-label--compact {
font-size: 0.7rem;
width: clamp(6em, 50cqw, 9em);
flex-shrink: 0;
}
} }
.badge-scroll-container { .badge-scroll-container {
@@ -25,5 +25,4 @@ defineProps<{
background-size: cover; background-size: cover;
min-width: 300px; min-width: 300px;
} }
</style> </style>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from "@inertiajs/vue3" import { Link, usePage } from "@inertiajs/vue3"
import { SharedProps } from "@/Types/types" import { SharedProps } from "@/Types/types"
import CopyPageButton from "@/Components/FlightsGoneBy/CopyPageButton.vue";
const page = usePage<SharedProps>().props const page = usePage<SharedProps>().props
const year = new Date().getFullYear() const year = new Date().getFullYear()
@@ -63,9 +64,17 @@ footer {
color: #ffc107; color: #ffc107;
} }
.footer-version {
opacity: 0.8;
margin-left: 0.4em;
}
@media (max-width: 600px) { @media (max-width: 600px) {
footer { footer {
padding: 0 1rem; flex-wrap: wrap;
flex: 0 0 auto;
row-gap: 0.5rem;
padding: 0.6rem 1rem;
} }
.footer-copyright { .footer-copyright {
@@ -75,10 +84,6 @@ footer {
.footer-link { .footer-link {
font-size: 0.6rem; font-size: 0.6rem;
} }
}
.footer-version {
opacity: 0.8;
margin-left: 0.4em;
} }
</style> </style>
@@ -5,6 +5,7 @@ import type { SharedProps } from '@/Types/types'
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import NotificationMenu from "@/Components/FlightsGoneBy/NotificationMenu.vue"; import NotificationMenu from "@/Components/FlightsGoneBy/NotificationMenu.vue";
import Logo from "@/Components/FlightsGoneBy/Logo.vue"; import Logo from "@/Components/FlightsGoneBy/Logo.vue";
import UserSearchBox from "@/Components/UserSearchBox.vue";
const page = usePage<SharedProps>() const page = usePage<SharedProps>()
const menuOpen = ref(false) const menuOpen = ref(false)
@@ -30,6 +31,7 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
<Logo variant="color" /> <Logo variant="color" />
</Link> </Link>
<UserSearchBox class="nav-desktop" density="compact" style="max-width:400px; width: 400px;" />
<NotificationMenu v-if="page.props.auth?.user" :unread-count="page.props.unread_notification_count" /> <NotificationMenu v-if="page.props.auth?.user" :unread-count="page.props.unread_notification_count" />
<!-- Desktop nav --> <!-- Desktop nav -->
@@ -369,5 +371,6 @@ header {
@media (max-width: 600px) { @media (max-width: 600px) {
.nav-desktop { display: none; } .nav-desktop { display: none; }
.hamburger { display: flex; } .hamburger { display: flex; }
.user-search-box { display: none; }
} }
</style> </style>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted, onUnmounted } from 'vue'
import axios from "axios"; import axios from "axios";
import { Notification } from "@/Types/types"; import { Notification } from "@/Types/types";
import { Link } from "@inertiajs/vue3"; import { Link } from "@inertiajs/vue3";
@@ -18,6 +18,7 @@ onMounted(() => {
const open = ref(false) const open = ref(false)
const notifications = ref<Notification[]>([]) const notifications = ref<Notification[]>([])
const loading = ref(false) const loading = ref(false)
const wrapperRef = ref<HTMLElement | null>(null)
const markAllRead = async (notifications: Notification[]) => { const markAllRead = async (notifications: Notification[]) => {
const unread = notifications.filter(n => !n.read_at) const unread = notifications.filter(n => !n.read_at)
@@ -27,6 +28,20 @@ const markAllRead = async (notifications: Notification[]) => {
unread.forEach(n => n.read_at = new Date().toISOString()) unread.forEach(n => n.read_at = new Date().toISOString())
} }
const handleClickOutside = (event: MouseEvent) => {
if (open.value && wrapperRef.value && !wrapperRef.value.contains(event.target as Node)) {
open.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
watch(open, async (isOpen) => { watch(open, async (isOpen) => {
if (!isOpen) return if (!isOpen) return
@@ -43,7 +58,7 @@ watch(open, async (isOpen) => {
</script> </script>
<template> <template>
<div class="notif-wrapper"> <div class="notif-wrapper" ref="wrapperRef">
<v-btn icon variant="text" @click="open = !open" aria-label="Notifications"> <v-btn icon variant="text" @click="open = !open" aria-label="Notifications">
<v-badge :content="store.unreadCount" :model-value="store.unreadCount > 0" color="primary"> <v-badge :content="store.unreadCount" :model-value="store.unreadCount > 0" color="primary">
<v-icon>mdi-bell-outline</v-icon> <v-icon>mdi-bell-outline</v-icon>
@@ -106,6 +121,16 @@ watch(open, async (isOpen) => {
overflow: hidden; overflow: hidden;
} }
@media (max-width: 480px) {
.notif-menu {
position: fixed;
top: 4.5rem; /* adjust to sit just below your navbar height */
left: 50%;
transform: translateX(-50%);
width: min(320px, calc(100vw - 1.5rem));
}
}
.notif-header { .notif-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -4,6 +4,7 @@ import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue"; import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue"; import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
import DetailRow from "@/Components/FlightsGoneBy/Panels/DetailRow.vue"; import DetailRow from "@/Components/FlightsGoneBy/Panels/DetailRow.vue";
import PanelLabel from "@/Components/FlightsGoneBy/Panels/PanelLabel.vue";
defineProps<{ defineProps<{
flight: Flight flight: Flight
@@ -19,6 +20,10 @@ defineProps<{
<DetailRow label="Distance" :value="flight.distance + 'km'" /> <DetailRow label="Distance" :value="flight.distance + 'km'" />
<DetailRow label="Duration" :value="flight.duration_display" /> <DetailRow label="Duration" :value="flight.duration_display" />
</DetailRows> </DetailRows>
<DetailRows>
<PanelLabel v-if="flight.note?.length">Notes</PanelLabel>
{{flight.note}}
</DetailRows>
</Panel> </Panel>
</template> </template>
@@ -16,7 +16,7 @@ const store = useNotificationStore();
max-width="360" max-width="360"
> >
<v-card-text class="d-flex align-center ga-3"> <v-card-text class="d-flex align-center ga-3">
<v-icon icon="mdi-trophy" color="amber" size="32" /> <v-icon :icon="notification.is_achievement ? 'mdi-trophy' : 'mdi-bell-ring'" color="amber" size="32" />
<div> <div>
<div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div> <div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div>
<div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div> <div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import GlassTooltip from '@/Components/FlightsGoneBy/GlassTooltip.vue'
import type { Anchor } from 'vuetify'
withDefaults(defineProps<{
label?: string
location?: Anchor
}>(), {
label: 'Missing something?',
})
defineSlots<{
default(): any
}>()
</script>
<template>
<GlassTooltip :location="location ?? 'top'" :open-delay="150">
<template #activator="{ props: activatorProps }">
<button
v-bind="activatorProps"
type="button"
class="missing-entity-link"
>
{{ label }}
</button>
</template>
<template #default>
<slot />
</template>
</GlassTooltip>
</template>
<style scoped>
.missing-entity-link {
display: inline-block;
margin-top: 4px;
padding: 0;
background: none;
border: none;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
transition: color 0.15s ease;
}
.missing-entity-link:hover {
color: rgba(255, 255, 255, 0.85);
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Link } from '@inertiajs/vue3'
defineProps<{
density?: null | 'default' | 'comfortable' | 'compact';
}>()
interface UserOption {
value: number
title: string
}
const search = ref('')
const selectedUser = ref<UserOption | null>(null)
const items = ref<UserOption[]>([])
const loading = ref(false)
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
async function fetchUsers(query: string) {
if (!query) {
items.value = []
return
}
loading.value = true
try {
const response = await fetch(`/search/users?q=${encodeURIComponent(query)}`)
if (!response.ok) throw new Error('Failed to fetch users')
items.value = await response.json()
} catch (error) {
console.error('User search failed:', error)
items.value = []
} finally {
loading.value = false
}
}
watch(search, (newQuery) => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
fetchUsers(newQuery)
}, 300)
})
</script>
<template>
<div class="user-search-box">
<v-autocomplete
v-model="selectedUser"
v-model:search="search"
:items="items"
:loading="loading"
item-title="title"
item-value="value"
return-object
label="Search users"
placeholder="Type a name..."
prepend-inner-icon="mdi-magnify"
:density="density ?? 'default'"
variant="outlined"
no-filter
clearable
hide-details
hide-no-data
autocomplete="off"
>
<template #item="{ item }">
<Link :href="`/u/${item.title}`" class="user-search-result">
<v-list-item :title="item.title" />
</Link>
</template>
</v-autocomplete>
</div>
</template>
<style scoped>
.user-search-box {
display: block;
align-self: center;
width: 100%;
}
</style>
+86 -35
View File
@@ -89,32 +89,51 @@ export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
// ── Grouping helpers ────────────────────────────────────────────────────────── // ── Grouping helpers ──────────────────────────────────────────────────────────
function groupByName(flights: Flight[], accessor: (f: Flight) => string) { function groupByNameSplit(flights: Flight[], upcomingFlights: Flight[], accessor: (f: Flight) => string) {
const counts = new Map<string, number>() const countOne = (list: Flight[]) => {
flights.forEach(f => { const counts = new Map<string, number>()
const key = accessor(f) ?? 'Unknown' list.forEach(f => {
counts.set(key, (counts.get(key) ?? 0) + 1) const key = accessor(f) ?? 'Unknown'
}) counts.set(key, (counts.get(key) ?? 0) + 1)
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]) })
return counts
}
const past = countOne(flights)
const upcoming = countOne(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
const sorted = [...allNames]
.map(name => ({
name,
past: past.get(name) ?? 0,
upcoming: upcoming.get(name) ?? 0,
}))
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
return { return {
labels: sorted.map(([name]) => name), labels: sorted.map(s => s.name),
series: sorted.map(([, count]) => count), series: [
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
} }
} }
export function getFlightReasons(flights: Flight[]) { export function getFlightReasons(flights: Flight[], upcomingFlights: Flight[]) {
const result = groupByName(flights, f => f.flight_reason?.name ?? 'Unknown') return groupByNameSplit(flights, upcomingFlights, f => f.flight_reason?.name ?? 'Unknown')
return result
} }
export function getFlightClasses(flights: Flight[]) { export function getFlightClasses(flights: Flight[], upcomingFlights: Flight[]) {
const result = groupByName(flights, f => f.flight_class?.name ?? 'Unknown') return groupByNameSplit(flights, upcomingFlights, f => f.flight_class?.name ?? 'Unknown')
return result
} }
export function getSeatTypes(flights: Flight[]) { export function getSeatTypes(flights: Flight[], upcomingFlights: Flight[]) {
const result = groupByName(flights, f => f.seat_type?.name ?? 'Unknown') return groupByNameSplit(flights, upcomingFlights, f => f.seat_type?.name ?? 'Unknown')
return result }
export function getAlliances(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.airline?.alliance?.name ?? 'No Alliance')
} }
// ── Countries ───────────────────────────────────────────────────────────────── // ── Countries ─────────────────────────────────────────────────────────────────
@@ -156,7 +175,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
countries: sorted, countries: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -165,7 +184,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
// ── Continents ──────────────────────────────────────────────────────────────── // ── Continents ────────────────────────────────────────────────────────────────
export function getContinents(flights: Flight[]) { function countContinents(flights: Flight[]) {
const counts = new Map<string, number>() const counts = new Map<string, number>()
flights.forEach(f => { flights.forEach(f => {
const continents = new Set<string>() const continents = new Set<string>()
@@ -175,10 +194,28 @@ export function getContinents(flights: Flight[]) {
if (arr) continents.add(arr) if (arr) continents.add(arr)
continents.forEach(c => counts.set(c, (counts.get(c) ?? 0) + 1)) continents.forEach(c => counts.set(c, (counts.get(c) ?? 0) + 1))
}) })
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]) return counts
}
export function getContinents(flights: Flight[], upcomingFlights: Flight[]) {
const past = countContinents(flights)
const upcoming = countContinents(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
const sorted = [...allNames]
.map(name => ({
name,
past: past.get(name) ?? 0,
upcoming: upcoming.get(name) ?? 0,
}))
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
const result = { const result = {
labels: sorted.map(([name]) => name), labels: sorted.map(s => s.name),
series: sorted.map(([, count]) => count), series: [
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
} }
return result return result
} }
@@ -215,7 +252,7 @@ export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
airlines: sorted, airlines: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -338,7 +375,7 @@ export function getTopAircraft(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
aircraft: sorted, aircraft: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -391,7 +428,7 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
routes: sorted, routes: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -400,7 +437,7 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
// ── Flight types ────────────────────────────────────────────────────────────── // ── Flight types ──────────────────────────────────────────────────────────────
export function getFlightTypes(flights: Flight[]) { function countFlightTypes(flights: Flight[]) {
const counts = { International: 0, Domestic: 0 } const counts = { International: 0, Domestic: 0 }
flights.forEach(f => { flights.forEach(f => {
const dep = f.departure_airport.region?.country?.id const dep = f.departure_airport.region?.country?.id
@@ -409,10 +446,22 @@ export function getFlightTypes(flights: Flight[]) {
dep === arr ? counts.Domestic++ : counts.International++ dep === arr ? counts.Domestic++ : counts.International++
} }
}) })
const sorted = Object.entries(counts).filter(([, count]) => count > 0) return counts
}
export function getFlightTypes(flights: Flight[], upcomingFlights: Flight[]) {
const past = countFlightTypes(flights)
const upcoming = countFlightTypes(upcomingFlights)
const names = ['International', 'Domestic'] as const
const relevant = names.filter(name => past[name] > 0 || upcoming[name] > 0)
const result = { const result = {
labels: sorted.map(([name]) => name), labels: relevant,
series: sorted.map(([, count]) => count), series: [
{ name: 'Flown', data: relevant.map(name => past[name]) },
{ name: 'Upcoming', data: relevant.map(name => upcoming[name]) },
],
} }
return result return result
} }
@@ -443,16 +492,17 @@ export function useFlightStats(flights: Ref<Flight[]>) {
const perYear = computed(() => getFlightsPerYear(pastFlights.value, upcomingFlights.value)) const perYear = computed(() => getFlightsPerYear(pastFlights.value, upcomingFlights.value))
const perMonth = computed(() => getFlightsPerMonth(pastFlights.value, upcomingFlights.value)) const perMonth = computed(() => getFlightsPerMonth(pastFlights.value, upcomingFlights.value))
const perDay = computed(() => getFlightsPerDay(pastFlights.value, upcomingFlights.value)) const perDay = computed(() => getFlightsPerDay(pastFlights.value, upcomingFlights.value))
const reasons = computed(() => getFlightReasons(allFlights.value)) const reasons = computed(() => getFlightReasons(pastFlights.value, upcomingFlights.value))
const classes = computed(() => getFlightClasses(allFlights.value)) const classes = computed(() => getFlightClasses(pastFlights.value, upcomingFlights.value))
const seatTypes = computed(() => getSeatTypes(allFlights.value)) const seatTypes = computed(() => getSeatTypes(pastFlights.value, upcomingFlights.value))
const alliances = computed(() => getAlliances(pastFlights.value, upcomingFlights.value))
const countries = computed(() => getCountries(pastFlights.value, upcomingFlights.value)) const countries = computed(() => getCountries(pastFlights.value, upcomingFlights.value))
const continents = computed(() => getContinents(allFlights.value)) const continents = computed(() => getContinents(pastFlights.value, upcomingFlights.value))
const topAirlines = computed(() => getTopAirlines(pastFlights.value, upcomingFlights.value)) const topAirlines = computed(() => getTopAirlines(pastFlights.value, upcomingFlights.value))
const topAirports = computed(() => getTopAirports(pastFlights.value, upcomingFlights.value)) const topAirports = computed(() => getTopAirports(pastFlights.value, upcomingFlights.value))
const topAircraft = computed(() => getTopAircraft(pastFlights.value, upcomingFlights.value)) const topAircraft = computed(() => getTopAircraft(pastFlights.value, upcomingFlights.value))
const topRoutes = computed(() => getTopRoutes(pastFlights.value, upcomingFlights.value)) const topRoutes = computed(() => getTopRoutes(pastFlights.value, upcomingFlights.value))
const flightTypes = computed(() => getFlightTypes(allFlights.value)) const flightTypes = computed(() => getFlightTypes(pastFlights.value, upcomingFlights.value))
return { return {
pastFlights, pastFlights,
@@ -463,6 +513,7 @@ export function useFlightStats(flights: Ref<Flight[]>) {
reasons, reasons,
classes, classes,
seatTypes, seatTypes,
alliances,
countries, countries,
continents, continents,
flightTypes, flightTypes,
+18
View File
@@ -9,6 +9,7 @@ import { ref } from "vue";
import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue"; import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue";
import {SharedProps} from "@/Types/types"; import {SharedProps} from "@/Types/types";
import PushNotificationPrompt from "@/Components/PushNotificationPrompt.vue"; import PushNotificationPrompt from "@/Components/PushNotificationPrompt.vue";
import CopyPageButton from "@/Components/FlightsGoneBy/CopyPageButton.vue";
const transitionKey = ref(0); const transitionKey = ref(0);
const page = usePage<SharedProps>().props const page = usePage<SharedProps>().props
@@ -27,6 +28,9 @@ router.on('success', () => {
<slot /> <slot />
</main> </main>
</Transition> </Transition>
<span class="footer-share">
<CopyPageButton label="Share Link" />
</span>
<MainFooter :key="`footer-${transitionKey}`" /> <MainFooter :key="`footer-${transitionKey}`" />
</div> </div>
<NotificationListener v-if="page.auth.user?.id" :user-id="page.auth.user?.id" /> <NotificationListener v-if="page.auth.user?.id" :user-id="page.auth.user?.id" />
@@ -51,6 +55,14 @@ footer {
min-height: 3rem; min-height: 3rem;
} }
@media (max-width: 600px) {
footer {
flex: 0 1 auto;
min-height: 3.25rem;
padding-block: 0.35rem;
}
}
main { main {
flex: 1 1 auto; flex: 1 1 auto;
background: transparent; background: transparent;
@@ -60,6 +72,12 @@ main {
align-items: center; align-items: center;
} }
.footer-share {
padding:1em;
display: flex;
justify-content: center;
}
.fade-enter-from, .fade-enter-from,
.fade-leave-to { opacity: 0; } .fade-leave-to { opacity: 0; }
.fade-enter-active, .fade-enter-active,
+522 -109
View File
@@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import MainLayout from '@/Layouts/MainLayout.vue' import MainLayout from '@/Layouts/MainLayout.vue'
import GlassBox from '@/Components/FlightsGoneBy/GlassBox.vue' import GlassBox from '@/Components/FlightsGoneBy/GlassBox.vue'
import { Head, useForm } from '@inertiajs/vue3' import {Head, useForm, usePage} from '@inertiajs/vue3'
import AirlineSearchBox from '@/Components/FlightsGoneBy/AirlineSearchBox.vue' import AirlineSearchBox from '@/Components/FlightsGoneBy/AirlineSearchBox.vue'
import AircraftSearchBox from '@/Components/FlightsGoneBy/AircraftSearchBox.vue' import AircraftSearchBox from '@/Components/FlightsGoneBy/AircraftSearchBox.vue'
import AirportSearchBox from '@/Components/FlightsGoneBy/AirportSearchBox.vue' import AirportSearchBox from '@/Components/FlightsGoneBy/AirportSearchBox.vue'
import type {SeatType, FlightReason, FlightClass, CrewType} from '@/Types/types' import type {SeatType, FlightReason, FlightClass, CrewType, SharedProps} from '@/Types/types'
import { ref, watch, computed } from 'vue' import { ref, watch, computed, onMounted, nextTick } from 'vue'
import { VTimePicker, VDatePicker } from 'vuetify/components' import { VTimePicker, VDatePicker } from 'vuetify/components'
import MissingEntityLink from "@/Components/FlightsGoneBy/Tooltips/MissingEntityLink.vue";
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
const page = usePage<SharedProps>().props
const props = defineProps<{ const props = defineProps<{
flight?: { flight?: {
@@ -39,30 +40,23 @@ const props = defineProps<{
const isEdit = !!props.flight const isEdit = !!props.flight
// User format preferences
const timeFormat = computed(() => page.auth?.user?.resolved_settings?.time_format ?? 'ampm')
const pad = (n: number) => String(n).padStart(2, '0')
// Flight number lookup // Flight number lookup
const flightNumber = ref(props.flight?.flight_number ?? '') const flightNumber = ref(props.flight?.flight_number ?? '')
const lookupLoading = ref(false) const lookupLoading = ref(false)
const lookupError = ref<string | null>(null) const lookupError = ref<string | null>(null)
const lookupComplete = ref(true)
interface LookupResult {
airline_options: { value: number; title: string, logo_url: string }[]
from_options: { value: number; title: string; country_code: string }[]
to_options: { value: number; title: string; country_code: string }[]
aircraft_options: { value: number; title: string }[]
scheduled_departure_time: string | null
scheduled_arrival_time: string | null
}
const lookupResult = ref<LookupResult | null>(null)
const lookupKey = ref(0) const lookupKey = ref(0)
async function lookupFlight() { async function lookupFlight() {
if (!flightNumber.value.trim()) return if (!flightNumber.value.trim()) return
lookupLoading.value = true lookupLoading.value = true
lookupError.value = null lookupError.value = null
lookupResult.value = null
try { try {
const response = await fetch(`${route('flights.lookup')}?number=${encodeURIComponent(flightNumber.value.trim())}`, { const response = await fetch(`${route('flights.lookup')}?number=${encodeURIComponent(flightNumber.value.trim())}`, {
@@ -73,8 +67,6 @@ async function lookupFlight() {
lookupError.value = data.message ?? 'Lookup failed.' lookupError.value = data.message ?? 'Lookup failed.'
return return
} }
lookupResult.value = data
lookupComplete.value = true
if (data.airline_options?.length) { if (data.airline_options?.length) {
airlineOptionsData.value = data.airline_options airlineOptionsData.value = data.airline_options
if (!form.airline) form.airline = data.airline_options[0] if (!form.airline) form.airline = data.airline_options[0]
@@ -106,6 +98,12 @@ async function lookupFlight() {
} }
suppressArrivalAutoFill = false suppressArrivalAutoFill = false
// NOTE: if the lookup API is ever extended to return a day
// difference for the arrival (e.g. `data.arrival_day_offset`),
// wire it in here: `arrivalOffset.value = data.arrival_day_offset`
// (clamped/mapped to 'custom' if outside -1..2). Left out for now
// since the API doesn't provide it yet.
lookupKey.value++ lookupKey.value++
} catch (e) { } catch (e) {
lookupError.value = String(e) lookupError.value = String(e)
@@ -117,7 +115,6 @@ async function lookupFlight() {
// Display form (drives the template) // Display form (drives the template)
const form = useForm({ const form = useForm({
flight_number: props.flight?.flight_number ?? '',
departure_date: props.flight?.departure_date ?? '', departure_date: props.flight?.departure_date ?? '',
arrival_date: props.flight?.arrival_date ?? '', arrival_date: props.flight?.arrival_date ?? '',
from: props.flight?.from_options[0] ?? null as { value: number; title: string; country_code: string } | null, from: props.flight?.from_options[0] ?? null as { value: number; title: string; country_code: string } | null,
@@ -158,9 +155,9 @@ const submitForm = useForm({
crew_type_id: null as number | null, crew_type_id: null as number | null,
note: '' as string | null, note: '' as string | null,
auto_update: false, auto_update: false,
add_more: false,
}) })
const departureIsFuture = computed(() => { const departureIsFuture = computed(() => {
if (!form.departure_date) return false if (!form.departure_date) return false
return new Date(form.departure_date) > new Date() return new Date(form.departure_date) > new Date()
@@ -172,7 +169,7 @@ watch(departureIsFuture, (isFuture) => {
}, { immediate: true }) }, { immediate: true })
function submit() { function submit(addMore = false) {
submitForm.flight_number = flightNumber.value submitForm.flight_number = flightNumber.value
submitForm.departure_date = form.departure_date submitForm.departure_date = form.departure_date
submitForm.arrival_date = form.arrival_date submitForm.arrival_date = form.arrival_date
@@ -188,6 +185,7 @@ function submit() {
submitForm.crew_type_id = form.crew_type?.id ?? null submitForm.crew_type_id = form.crew_type?.id ?? null
submitForm.note = form.note submitForm.note = form.note
submitForm.auto_update = form.auto_update submitForm.auto_update = form.auto_update
submitForm.add_more = addMore
if (isEdit) { if (isEdit) {
submitForm.put(route('flights.update', { flight: props.flight!.id })) submitForm.put(route('flights.update', { flight: props.flight!.id }))
@@ -209,8 +207,6 @@ const aircraftOptionsData = ref<{ value: number; title: string }[]>(props.flight
// on. These refs + watchers just give the template two separate pickers while // on. These refs + watchers just give the template two separate pickers while
// keeping those combined strings in sync no server-side changes needed. // keeping those combined strings in sync no server-side changes needed.
const pad = (n: number) => String(n).padStart(2, '0')
function parseDateTime(dt: string | null | undefined) { function parseDateTime(dt: string | null | undefined) {
if (!dt) return { date: null as Date | null, time: null as string | null } if (!dt) return { date: null as Date | null, time: null as string | null }
const d = new Date(dt) const d = new Date(dt)
@@ -227,26 +223,15 @@ function combineDateTime(date: Date | null, time: string | null): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${(hh ?? '00').padStart(2, '0')}:${(mm ?? '00').padStart(2, '0')}` return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${(hh ?? '00').padStart(2, '0')}:${(mm ?? '00').padStart(2, '0')}`
} }
const formatDateDisplay = (d: Date | null) => // Whole-day difference between two Date objects (time-of-day ignored).
d ? d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '' function diffInDays(from: Date | null, to: Date | null): number | null {
if (!from || !to) return null
// Time is stored internally as a 24hr "HH:mm" string (VTimePicker's model const a = new Date(from.getFullYear(), from.getMonth(), from.getDate())
// value is always 24hr regardless of the `format` display prop). This just const b = new Date(to.getFullYear(), to.getMonth(), to.getDate())
// formats it for display in the read-only text fields. return Math.round((b.getTime() - a.getTime()) / 86_400_000)
function formatTimeDisplay(t: string | null): string {
if (!t) return ''
const [hStr, mStr] = t.split(':')
let h = parseInt(hStr, 10)
if (isNaN(h)) return ''
const ampm = h >= 12 ? 'PM' : 'AM'
h = h % 12
if (h === 0) h = 12
return `${h}:${mStr} ${ampm}`
} }
function clearDepartureDate() { departureDatePart.value = null }
function clearDepartureTime() { departureTimePart.value = null } function clearDepartureTime() { departureTimePart.value = null }
function clearArrivalDate() { arrivalDatePart.value = null }
function clearArrivalTime() { arrivalTimePart.value = null } function clearArrivalTime() { arrivalTimePart.value = null }
// Normalizes whatever shape the API sends a scheduled time in ("14:30", // Normalizes whatever shape the API sends a scheduled time in ("14:30",
@@ -292,19 +277,88 @@ watch([arrivalDatePart, arrivalTimePart], () => {
form.arrival_date = combineDateTime(arrivalDatePart.value, arrivalTimePart.value) form.arrival_date = combineDateTime(arrivalDatePart.value, arrivalTimePart.value)
}) })
// Auto-fill arrival (+1hr) the first time a departure TIME is chosen (and a // Arrival day offset
// departure date is already set) and arrival is still empty. Picking the // Rather than making the user pick an independent arrival date, arrival date
// departure date alone no longer triggers this only setting the time does. // is normally *derived* from departure date + a small relative offset
// (-1 / Same day / +1 / +2). "Custom" drops back to a real date picker for
// the rare case that falls outside that range. This avoids needing airport
// time zones to work out the arrival date, which a duration-based input
// would require.
type ArrivalOffset = -1 | 0 | 1 | 2
const arrivalOffsetOptions: { value: ArrivalOffset; label: string }[] = [
{ value: -1, label: '1 Day' },
{ value: 0, label: 'Same Day' },
{ value: 1, label: '+1 Day' },
{ value: 2, label: '+2 Days' },
]
const initialDiff = diffInDays(depInit.date, arrInit.date)
const initialDiffIsQuickOption = initialDiff !== null && ([-1, 0, 1, 2] as number[]).includes(initialDiff)
// 'offset' (the default): arrival date is derived from departure date + the
// selected quick offset. 'custom': the user has dropped into a real date
// picker, e.g. because the actual gap is more than +2 days.
const arrivalDateMode = ref<'offset' | 'custom'>(
initialDiff !== null && !initialDiffIsQuickOption ? 'custom' : 'offset'
)
const arrivalOffset = ref<ArrivalOffset>(
initialDiffIsQuickOption ? (initialDiff as ArrivalOffset) : 0
)
function applyArrivalOffset() {
if (suppressArrivalAutoFill) return
if (arrivalDateMode.value === 'custom') return
if (!departureDatePart.value) {
arrivalDatePart.value = null
return
}
const d = new Date(departureDatePart.value)
d.setDate(d.getDate() + arrivalOffset.value)
arrivalDatePart.value = d
}
// Re-derive arrival date whenever departure date moves, or the offset is
// changed by hand.
watch(departureDatePart, applyArrivalOffset)
watch(arrivalOffset, applyArrivalOffset)
function useCustomArrivalDate() {
arrivalDateMode.value = 'custom'
}
function useQuickArrivalOffset() {
arrivalDateMode.value = 'offset'
applyArrivalOffset()
}
function formatFullDate(d: Date | null): string {
if (!d) return ''
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })
}
// Auto-fill arrival TIME (+1hr) the first time a departure time is chosen
// (and a departure date is already set) and arrival time is still empty.
// If adding the hour rolls over midnight, bump the offset selector to match
// (e.g. departs 23:30 -> offset flips to "+1") so the resolved arrival date
// stays correct without the user having to notice and fix it themselves.
watch(departureTimePart, (newTime) => { watch(departureTimePart, (newTime) => {
if (suppressArrivalAutoFill) return if (suppressArrivalAutoFill) return
if (!newTime || !departureDatePart.value || arrivalDatePart.value) return if (arrivalDateMode.value === 'custom') return
if (!newTime || !departureDatePart.value || arrivalTimePart.value) return
const dep = new Date(departureDatePart.value) const dep = new Date(departureDatePart.value)
const [h, m] = newTime.split(':').map(Number) const [h, m] = newTime.split(':').map(Number)
dep.setHours(h, m) dep.setHours(h, m)
const arr = new Date(dep.getTime() + 60 * 60 * 1000) const arr = new Date(dep.getTime() + 60 * 60 * 1000)
const rawDiff = diffInDays(departureDatePart.value, arr) ?? 0
const clampedDiff = Math.min(Math.max(rawDiff, -1), 2) as -1 | 0 | 1 | 2
suppressArrivalAutoFill = true suppressArrivalAutoFill = true
arrivalOffset.value = clampedDiff
arrivalDatePart.value = new Date(arr.getFullYear(), arr.getMonth(), arr.getDate()) arrivalDatePart.value = new Date(arr.getFullYear(), arr.getMonth(), arr.getDate())
arrivalTimePart.value = `${pad(arr.getHours())}:${pad(arr.getMinutes())}` arrivalTimePart.value = `${pad(arr.getHours())}:${pad(arr.getMinutes())}`
suppressArrivalAutoFill = false suppressArrivalAutoFill = false
@@ -324,6 +378,170 @@ const arrivalMax = computed(() => getArrivalBound(form, 3))
const arrivalMinDate = computed(() => arrivalMin.value ? new Date(arrivalMin.value) : undefined) const arrivalMinDate = computed(() => arrivalMin.value ? new Date(arrivalMin.value) : undefined)
const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.value) : undefined) const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.value) : undefined)
// Native date inputs
// departureDatePart / arrivalDatePart stay the source of truth as Date
// objects. The <input type="date"> elements below are deliberately left
// UNCONTROLLED from Vue's perspective no v-model, no :model-value, no
// reactive :value binding of any kind. That's on purpose: any reactive
// binding through Vuetify's v-text-field even one that only commits on
// blur/change, not every keystroke still runs through v-text-field's own
// internal echo of the field, which re-renders the wrapped <input> on every
// keystroke regardless of what we bind from outside. For type="date"
// specifically, that internal re-render resets the browser's own
// per-segment editing state mid-type (day/month/year), which is what was
// scrambling what you typed. Using a genuinely uncontrolled native <input>
// (not wrapped in v-text-field at all) sidesteps that entirely.
//
// We grab a direct DOM ref to each native input and manage its .value
// imperatively instead:
// - once on mount, to show the initial value
// - whenever departureDatePart/arrivalDatePart changes from elsewhere
// (the calendar picker, a flight lookup, auto-fill) but only if the
// field isn't currently focused, so we never fight with active typing
// - the native `change` event (fires once a full date has been entered
// and committed, or cleared) is what flows the typed value back into
// departureDatePart/arrivalDatePart
// This keeps the real native, locale-aware date input and picker UI intact.
//
// The arrival native date input only exists in the DOM while arrivalOffset
// is 'custom' (see template); its value is (re)synced whenever it mounts.
function toIsoDateString(d: Date | null): string {
if (!d) return ''
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
function fromIsoDateString(value: string): Date | null {
if (!value) return null
const [y, m, d] = value.split('-').map(Number)
if (!y || !m || !d) return null
return new Date(y, m - 1, d)
}
const departureDateEl = ref<HTMLInputElement | null>(null)
const arrivalDateEl = ref<HTMLInputElement | null>(null)
onMounted(() => {
if (departureDateEl.value) departureDateEl.value.value = toIsoDateString(departureDatePart.value)
if (arrivalDateEl.value) arrivalDateEl.value.value = toIsoDateString(arrivalDatePart.value)
})
watch(departureDatePart, (d) => {
const el = departureDateEl.value
if (el && document.activeElement !== el) el.value = toIsoDateString(d)
})
watch(arrivalDatePart, (d) => {
const el = arrivalDateEl.value
if (el && document.activeElement !== el) el.value = toIsoDateString(d)
})
// The arrival native input only exists while arrivalDateMode === 'custom',
// so re-sync its value whenever it (re)mounts into the DOM.
watch(arrivalDateMode, async (mode) => {
if (mode !== 'custom') return
await nextTick()
if (arrivalDateEl.value) arrivalDateEl.value.value = toIsoDateString(arrivalDatePart.value)
})
function commitDepartureDateInput(e: Event) {
const value = (e.target as HTMLInputElement).value
departureDatePart.value = value ? fromIsoDateString(value) : null
}
function commitArrivalDateInput(e: Event) {
const value = (e.target as HTMLInputElement).value
arrivalDatePart.value = value ? fromIsoDateString(value) : null
}
// Editable, format-aware time text fields
// departureTimePart / arrivalTimePart stay the source of truth as internal
// "HH:mm" (24hr) strings. These input refs are what the text-fields actually
// bind to, so the user can type freely; parsing only commits on blur/enter.
function formatTimeForFormat(t: string | null, format: string): string {
if (!t) return ''
const [hStr, mStr] = t.split(':')
let h = parseInt(hStr, 10)
if (isNaN(h)) return ''
if (format === '24hr') {
return `${pad(h)}:${mStr}`
}
const ampm = h >= 12 ? 'PM' : 'AM'
let h12 = h % 12
if (h12 === 0) h12 = 12
return `${h12}:${mStr} ${ampm}`
}
function parseTimeInput(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
// ampm form: "2:30 PM", "2:30PM", "2 PM", "2PM"
let match = trimmed.match(/^(\d{1,2}):?(\d{2})?\s*([AaPp][Mm])$/)
if (match) {
let h = parseInt(match[1], 10)
const m = match[2] ? parseInt(match[2], 10) : 0
const ampm = match[3].toUpperCase()
if (h >= 1 && h <= 12 && m >= 0 && m <= 59) {
if (ampm === 'PM' && h !== 12) h += 12
if (ampm === 'AM' && h === 12) h = 0
return `${pad(h)}:${pad(m)}`
}
return null
}
// 24hr form: "14:30" or "1430"
match = trimmed.match(/^(\d{1,2}):?(\d{2})$/)
if (match) {
const h = parseInt(match[1], 10)
const m = parseInt(match[2], 10)
if (h >= 0 && h <= 23 && m >= 0 && m <= 59) {
return `${pad(h)}:${pad(m)}`
}
}
return null
}
const departureTimeInput = ref(formatTimeForFormat(departureTimePart.value, timeFormat.value))
const arrivalTimeInput = ref(formatTimeForFormat(arrivalTimePart.value, timeFormat.value))
watch([departureTimePart, timeFormat], () => {
departureTimeInput.value = formatTimeForFormat(departureTimePart.value, timeFormat.value)
})
watch([arrivalTimePart, timeFormat], () => {
arrivalTimeInput.value = formatTimeForFormat(arrivalTimePart.value, timeFormat.value)
})
function commitDepartureTimeInput() {
if (!departureTimeInput.value.trim()) {
departureTimePart.value = null
return
}
const parsed = parseTimeInput(departureTimeInput.value)
if (parsed !== null) {
departureTimePart.value = parsed
} else {
departureTimeInput.value = formatTimeForFormat(departureTimePart.value, timeFormat.value)
}
}
function commitArrivalTimeInput() {
if (!arrivalTimeInput.value.trim()) {
arrivalTimePart.value = null
return
}
const parsed = parseTimeInput(arrivalTimeInput.value)
if (parsed !== null) {
arrivalTimePart.value = parsed
} else {
arrivalTimeInput.value = formatTimeForFormat(arrivalTimePart.value, timeFormat.value)
}
}
</script> </script>
<template> <template>
@@ -368,44 +586,53 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
<!-- Departure date + time --> <!-- Departure date + time -->
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="depDateMenu" :close-on-content-click="false"> <div class="native-date-field">
<template #activator="{ props: menuProps }"> <v-icon icon="mdi-calendar" size="20" class="native-date-field__icon" />
<v-text-field <label class="native-date-field__label">Departure Date</label>
v-bind="menuProps" <input
:model-value="formatDateDisplay(departureDatePart)" ref="departureDateEl"
label="Departure Date" type="date"
readonly class="native-date-field__input"
clearable @change="commitDepartureDateInput"
prepend-inner-icon="mdi-calendar" >
:disabled="!lookupComplete" <v-menu v-model="depDateMenu" :close-on-content-click="false">
:error-messages="submitForm.errors.departure_date" <template #activator="{ props: menuProps }">
@click:clear="clearDepartureDate" <v-icon
v-bind="menuProps"
icon="mdi-calendar-month"
size="20"
style="cursor: pointer"
/>
</template>
<v-date-picker
v-model="departureDatePart"
@update:model-value="depDateMenu = false"
/> />
</template> </v-menu>
<v-date-picker </div>
v-model="departureDatePart" <div v-if="submitForm.errors.departure_date" class="native-date-field__error text-error">
@update:model-value="depDateMenu = false" {{ submitForm.errors.departure_date }}
/> </div>
</v-menu>
</v-col> </v-col>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="depTimeMenu" :close-on-content-click="false"> <v-menu v-model="depTimeMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }"> <template #activator="{ props: menuProps }">
<v-text-field <v-text-field
v-bind="menuProps" v-bind="menuProps"
:model-value="formatTimeDisplay(departureTimePart)" v-model="departureTimeInput"
label="Departure Time" label="Departure Time"
readonly
clearable clearable
prepend-inner-icon="mdi-clock-outline" prepend-inner-icon="mdi-clock-outline"
:disabled="!lookupComplete" :placeholder="timeFormat === '24hr' ? 'e.g. 1430' : 'e.g. 2:30 PM'"
@click:clear="clearDepartureTime" @click:clear="clearDepartureTime"
@blur="commitDepartureTimeInput"
@keydown.enter="commitDepartureTimeInput"
/> />
</template> </template>
<v-card> <v-card>
<v-time-picker <v-time-picker
v-model="departureTimePart" v-model="departureTimePart"
format="ampm" :format="timeFormat"
scrollable scrollable
/> />
<v-card-actions> <v-card-actions>
@@ -417,49 +644,102 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
</v-col> </v-col>
</v-row> </v-row>
<!-- Arrival date + time --> <!-- Arrival -->
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="arrDateMenu" :close-on-content-click="false"> <label class="arrival-offset__label">Arrival Date</label>
<template #activator="{ props: menuProps }">
<v-text-field <template v-if="arrivalDateMode === 'offset'">
v-bind="menuProps" <v-btn-toggle
:model-value="formatDateDisplay(arrivalDatePart)" v-model="arrivalOffset"
label="Arrival Date" mandatory
readonly variant="outlined"
clearable color="primary"
prepend-inner-icon="mdi-calendar" divided
:disabled="!lookupComplete" class="arrival-offset__toggle"
:error-messages="submitForm.errors.arrival_date" >
@click:clear="clearArrivalDate" <v-btn
/> v-for="opt in arrivalOffsetOptions"
</template> :key="opt.value"
<v-date-picker :value="opt.value"
v-model="arrivalDatePart" class="arrival-offset__btn"
:min="arrivalMinDate" >
:max="arrivalMaxDate" {{ opt.label }}
@update:model-value="arrDateMenu = false" </v-btn>
/> </v-btn-toggle>
</v-menu>
<div class="arrival-offset__footer">
<span v-if="arrivalDatePart" class="arrival-offset__hint">
Arrives {{ formatFullDate(arrivalDatePart) }}
</span>
<button
type="button"
class="arrival-offset__link"
@click="useCustomArrivalDate"
>
Custom date
</button>
</div>
</template>
<template v-else>
<div class="native-date-field">
<v-icon icon="mdi-calendar" size="20" class="native-date-field__icon" />
<input
ref="arrivalDateEl"
type="date"
class="native-date-field__input"
@change="commitArrivalDateInput"
>
<v-menu v-model="arrDateMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }">
<v-icon
v-bind="menuProps"
icon="mdi-calendar-month"
size="20"
style="cursor: pointer"
/>
</template>
<v-date-picker
v-model="arrivalDatePart"
:min="arrivalMinDate"
:max="arrivalMaxDate"
@update:model-value="arrDateMenu = false"
/>
</v-menu>
</div>
<button
type="button"
class="arrival-offset__link mt-1"
@click="useQuickArrivalOffset"
>
Use quick select instead
</button>
</template>
<div v-if="submitForm.errors.arrival_date" class="native-date-field__error text-error">
{{ submitForm.errors.arrival_date }}
</div>
</v-col> </v-col>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="arrTimeMenu" :close-on-content-click="false"> <v-menu v-model="arrTimeMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }"> <template #activator="{ props: menuProps }">
<v-text-field <v-text-field
v-bind="menuProps" v-bind="menuProps"
:model-value="formatTimeDisplay(arrivalTimePart)" v-model="arrivalTimeInput"
label="Arrival Time" label="Arrival Time"
readonly
clearable clearable
prepend-inner-icon="mdi-clock-outline" prepend-inner-icon="mdi-clock-outline"
:disabled="!lookupComplete" :placeholder="timeFormat === '24hr' ? 'e.g. 1430' : 'e.g. 2:30 PM'"
@click:clear="clearArrivalTime" @click:clear="clearArrivalTime"
@blur="commitArrivalTimeInput"
@keydown.enter="commitArrivalTimeInput"
/> />
</template> </template>
<v-card> <v-card>
<v-time-picker <v-time-picker
v-model="arrivalTimePart" v-model="arrivalTimePart"
format="ampm" :format="timeFormat"
scrollable scrollable
/> />
<v-card-actions> <v-card-actions>
@@ -479,8 +759,11 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
label="From" label="From"
:prefilled-options="fromOptionsData" :prefilled-options="fromOptionsData"
:error-messages="submitForm.errors.from_id" :error-messages="submitForm.errors.from_id"
:disabled="!lookupComplete"
/> />
<MissingEntityLink label="Missing airport?">
<p>If you can't find an airport we will be happy to add it, but you'll have to contact support with details of the airport.</p>
<p>Unfortunately you won't be able to proceed with adding this flight until the airport has been added.</p>
</MissingEntityLink>
</v-col> </v-col>
</v-row> </v-row>
@@ -492,7 +775,6 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
label="To" label="To"
:prefilled-options="toOptionsData" :prefilled-options="toOptionsData"
:error-messages="submitForm.errors.to_id" :error-messages="submitForm.errors.to_id"
:disabled="!lookupComplete"
/> />
</v-col> </v-col>
</v-row> </v-row>
@@ -505,8 +787,11 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
v-model="form.airline" v-model="form.airline"
:prefilled-options="airlineOptionsData" :prefilled-options="airlineOptionsData"
:error-messages="submitForm.errors.airline_id" :error-messages="submitForm.errors.airline_id"
:disabled="!lookupComplete"
/> />
<MissingEntityLink label="Missing airline? No problem">
<p>If you can't find the airline you flew with, just leave it blank but make sure to add a valid flight number. We will be notified of flights with missing airlines and add the airline shortly.</p>
<p>If you don't know your flight number or a missing airline hasn't been added within 48 hours, please contact support.</p>
</MissingEntityLink>
</v-col> </v-col>
</v-row> </v-row>
@@ -518,7 +803,6 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
v-model="form.aircraft" v-model="form.aircraft"
:prefilled-options="aircraftOptionsData" :prefilled-options="aircraftOptionsData"
:error-messages="submitForm.errors.aircraft_id" :error-messages="submitForm.errors.aircraft_id"
:disabled="!lookupComplete"
/> />
</v-col> </v-col>
</v-row> </v-row>
@@ -530,7 +814,6 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
v-model="form.aircraft_registration" v-model="form.aircraft_registration"
label="Aircraft Registration" label="Aircraft Registration"
placeholder="e.g. VH-OQA" placeholder="e.g. VH-OQA"
:disabled="!lookupComplete"
:error-messages="submitForm.errors.aircraft_registration" :error-messages="submitForm.errors.aircraft_registration"
/> />
</v-col> </v-col>
@@ -542,7 +825,6 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
item-title="name" item-title="name"
item-value="id" item-value="id"
:return-object="true" :return-object="true"
:disabled="!lookupComplete"
:error-messages="submitForm.errors.flight_class_id" :error-messages="submitForm.errors.flight_class_id"
/> />
</v-col> </v-col>
@@ -555,7 +837,6 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
v-model="form.seat_number" v-model="form.seat_number"
label="Seat Number" label="Seat Number"
placeholder="e.g. 12A" placeholder="e.g. 12A"
:disabled="!lookupComplete"
:error-messages="submitForm.errors.seat_number" :error-messages="submitForm.errors.seat_number"
/> />
</v-col> </v-col>
@@ -567,7 +848,6 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
item-title="name" item-title="name"
item-value="id" item-value="id"
:return-object="true" :return-object="true"
:disabled="!lookupComplete"
:error-messages="submitForm.errors.seat_type_id" :error-messages="submitForm.errors.seat_type_id"
/> />
</v-col> </v-col>
@@ -583,7 +863,6 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
item-title="name" item-title="name"
item-value="id" item-value="id"
:return-object="true" :return-object="true"
:disabled="!lookupComplete"
:error-messages="submitForm.errors.flight_reason_id" :error-messages="submitForm.errors.flight_reason_id"
/> />
</v-col> </v-col>
@@ -595,7 +874,6 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
item-title="name" item-title="name"
item-value="id" item-value="id"
:return-object="true" :return-object="true"
:disabled="!lookupComplete"
:error-messages="submitForm.errors.crew_type_id" :error-messages="submitForm.errors.crew_type_id"
/> />
</v-col> </v-col>
@@ -608,7 +886,6 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
v-model="form.note" v-model="form.note"
label="Note" label="Note"
placeholder="Any additional notes…" placeholder="Any additional notes…"
:disabled="!lookupComplete"
:error-messages="submitForm.errors.note" :error-messages="submitForm.errors.note"
rows="3" rows="3"
auto-grow auto-grow
@@ -622,7 +899,6 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
<v-checkbox <v-checkbox
v-model="form.auto_update" v-model="form.auto_update"
label="Automatically update flight details within 24 hours of flight departure" label="Automatically update flight details within 24 hours of flight departure"
:disabled="!lookupComplete"
hide-details hide-details
density="compact" density="compact"
/> />
@@ -631,15 +907,27 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
<!-- Submit --> <!-- Submit -->
<v-row> <v-row>
<v-col cols="12"> <v-col v-if="!isEdit" cols="12" md="6">
<v-btn <v-btn
block block
size="large" size="large"
:loading="submitForm.processing" variant="outlined"
:disabled="!lookupComplete || submitForm.processing" :loading="submitForm.processing && submitForm.add_more"
@click="submit" :disabled="submitForm.processing"
@click="submit(true)"
> >
{{ isEdit ? 'Save Changes' : 'Add Flight' }} Save Flight and Add Another
</v-btn>
</v-col>
<v-col cols="12" :md="isEdit ? 12 : 6">
<v-btn
block
size="large"
:loading="submitForm.processing && !submitForm.add_more"
:disabled="submitForm.processing"
@click="submit(false)"
>
{{ isEdit ? 'Save Changes' : 'Save Flight' }}
</v-btn> </v-btn>
</v-col> </v-col>
</v-row> </v-row>
@@ -650,4 +938,129 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
</template> </template>
<style scoped> <style scoped>
/* Hand-rolled field chrome for the native <input type="date"> fields.
These deliberately bypass v-text-field (see the "Native date inputs"
comment in the script block for why), so we approximate the surrounding
Vuetify outlined-field look here instead of getting it for free. */
.native-date-field {
position: relative;
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 8px 12px;
border: 1px none rgba(255, 255, 255, 0.24);
border-bottom-style: solid;
background: transparent;
transition: border-color 0.15s ease;
}
.native-date-field:focus-within {
border-color: rgb(var(--surface));
border-width: 2px;
padding: 7px 11px;
}
.native-date-field__icon {
opacity: 0.7;
flex-shrink: 0;
}
.native-date-field__label {
position: absolute;
top: -8px;
left: 38px;
padding: 0 4px;
font-size: 12px;
line-height: 1;
color: rgba(255, 255, 255, 0.6);
pointer-events: none;
}
.native-date-field__input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: inherit;
font-size: 16px;
color-scheme: dark;
}
.native-date-field__input::-webkit-calendar-picker-indicator {
display: none;
-webkit-appearance: none;
}
/* Firefox doesn't support ::-webkit-calendar-picker-indicator; this trims
its built-in indicator/spinner button area instead. */
.native-date-field__input::-moz-calendar-picker-indicator {
display: none;
}
.native-date-field__error {
margin-top: 4px;
padding-left: 12px;
font-size: 12px;
}
/* Arrival day-offset control */
.arrival-offset__label {
display: block;
font-size: 12px;
line-height: 1;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 6px;
padding-left: 2px;
}
.arrival-offset__toggle {
display: grid;
grid-template-columns: repeat(4, 1fr);
width: 100%;
height: 48px;
border-radius: 4px;
}
.arrival-offset__btn {
min-width: 0;
flex: none;
width: 100%;
height: 100%;
font-size: 11px;
letter-spacing: 0;
text-transform: none;
padding: 0 4px;
}
.arrival-offset__footer {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-top: 6px;
padding: 0 2px;
}
.arrival-offset__hint {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
.arrival-offset__link {
flex-shrink: 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.arrival-offset__link:hover {
color: rgb(255, 255, 255);
}
</style> </style>
+2 -1
View File
@@ -7,6 +7,7 @@ import {Head} from "@inertiajs/vue3";
defineProps<{ defineProps<{
title: string; title: string;
missingLiveryCount: number; missingLiveryCount: number;
missingAirlineCount: number;
}>() }>()
</script> </script>
@@ -15,7 +16,7 @@ defineProps<{
<MainLayout> <MainLayout>
<Head :title="title" /> <Head :title="title" />
<div class="admin-container"> <div class="admin-container">
<AdminSidebar :missingLiveryCount="missingLiveryCount" /> <AdminSidebar :missingAirlineCount="missingAirlineCount" :missingLiveryCount="missingLiveryCount" />
<div class="admin-content"> <div class="admin-content">
<GlassBox class="admin-page" :title="title"> <GlassBox class="admin-page" :title="title">
<slot /> <slot />
@@ -3,6 +3,7 @@ import {Link} from "@inertiajs/vue3";
import {number} from "echarts"; import {number} from "echarts";
defineProps<{ defineProps<{
missingLiveryCount: number; missingLiveryCount: number;
missingAirlineCount: number;
}>() }>()
</script> </script>
@@ -18,6 +19,11 @@ defineProps<{
Reconcile Missing Liveries Reconcile Missing Liveries
<v-chip size="x-small" color="error" class="ml-1">{{ missingLiveryCount }}</v-chip> <v-chip size="x-small" color="error" class="ml-1">{{ missingLiveryCount }}</v-chip>
</Link> </Link>
<Link v-if="missingAirlineCount" :href="route('admin.reconcile-missing-airlines')" class="sidebar-link">
<v-icon icon="mdi-airplane-takeoff" size="18" />
Reconcile Missing Airlines
<v-chip size="x-small" color="error" class="ml-1">{{ missingAirlineCount }}</v-chip>
</Link>
</aside> </aside>
</template> </template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import GlassBox from "@/Components/FlightsGoneBy/GlassBox.vue";
import { Head } from "@inertiajs/vue3";
import { MissingAirline } from "@/Types/types";
import MissingAirlineCard from "@/Components/Admin/MissingAirlineCard.vue";
import AdminLayout from "@/Pages/Admin/AdminLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
defineOptions({ layout: AdminLayout });
defineProps<{
missingAirlines: MissingAirline[]
}>()
</script>
<template>
<Head title="Missing Airlines" />
<Panel>
<template #header>
<h1 class="panel-title">Missing Airlines</h1>
<span class="panel-subtitle">
{{ missingAirlines.length }} unmatched code{{ missingAirlines.length === 1 ? '' : 's' }}
</span>
</template>
<div v-if="missingAirlines.length === 0" class="empty-state">
No missing airlines 🎉
</div>
<div v-else class="missing-airlines-grid">
<MissingAirlineCard
v-for="airline in missingAirlines"
:key="airline.code"
:airline="airline"
/>
</div>
</Panel>
</template>
<style scoped>
.panel-title {
font-family: 'Share Tech Mono', monospace;
font-size: 1.25rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #e6e9ef;
}
.panel-subtitle {
font-family: 'Share Tech Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #778;
}
.missing-airlines-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.empty-state {
font-family: 'Share Tech Mono', monospace;
font-size: 0.85rem;
color: #778;
padding: 2rem 0;
text-align: center;
}
</style>
+3 -1
View File
@@ -9,6 +9,7 @@ import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
import { useFlights } from "@/Composables/useFlights"; import { useFlights } from "@/Composables/useFlights";
import { useApiResource } from "@/Composables/useApiResource"; import { useApiResource } from "@/Composables/useApiResource";
import Avatar from "@/Components/FlightsGoneBy/Feed/Avatar.vue"; import Avatar from "@/Components/FlightsGoneBy/Feed/Avatar.vue";
import UserSearchBox from "@/Components/UserSearchBox.vue";
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
@@ -46,8 +47,9 @@ const { data: following, loading: followingLoading } = useApiResource<User[]>('/
</div> </div>
</section> </section>
<UserSearchBox />
<div class="feed-grid"> <div class="feed-grid">
<!-- FOLLOWING: secondary, supporting content -->
<aside class="following-box"> <aside class="following-box">
<h2>Following</h2> <h2>Following</h2>
<div v-if="followingLoading" class="following-loading"> <div v-if="followingLoading" class="following-loading">
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, Alliance, Flight, User } from '@/Types/types'
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import AllianceLogo from '@/Components/FlightsGoneBy/AllianceLogo.vue'
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
achievement: Achievement
user: User
followStatus: string
majorAlliances: Alliance[]
flights: Flight[]
}>()
interface AllianceEntry {
alliance: Alliance
flights: Flight[]
}
const alliances = computed<AllianceEntry[]>(() => {
const entries = props.majorAlliances.map(alliance => ({ alliance, flights: [] as Flight[] }))
const byName = new Map(entries.map(e => [e.alliance.internal_name, e]))
for (const flight of props.flights) {
const key = flight.airline?.alliance?.internal_name
if (!key) continue
byName.get(key)?.flights.push(flight)
}
return entries
})
const rows = computed(() => alliances.value.map(e => e.alliance.internal_name))
function entryFor(key: string): AllianceEntry {
return alliances.value.find(e => e.alliance.internal_name === key)!
}
const completedCount = computed(
() => alliances.value.filter(a => a.flights.length > 0).length
)
function flightTitle(flight: Flight): string {
const name = flight.airline?.name ?? 'Unknown airline'
return `${name}: ${flight.departure_airport?.iata_code}${flight.arrival_airport?.iata_code}`
}
</script>
<template>
<!-- Header -->
<Panel>
<div class="alliance-header">
<div class="logo-row">
<AllianceLogo
v-for="entry in alliances"
:key="entry.alliance.internal_name"
:alliance="entry.alliance"
size="56"
/>
</div>
</div>
</Panel>
<!-- Alliances table -->
<Panel>
<div class="table-toolbar">
<PanelHeader>Alliances</PanelHeader>
</div>
<BadgeTable
:rows="rows"
:rowKey="key => key"
:hasItems="key => entryFor(key).flights.length > 0"
labelWidth="14em"
>
<template #label="{ row: key }">
<div class="airline-label">
<AllianceLogo :alliance="entryFor(key).alliance" size="24" />
<span>{{ entryFor(key).alliance.name }}</span>
</div>
</template>
<template #items="{ row: key }">
<FlightBadge
v-for="flight in entryFor(key).flights"
:key="flight.id"
:title="flightTitle(flight)"
:flight="flight"
/>
</template>
</BadgeTable>
</Panel>
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly with at least one member airline from each of
the three major alliances. Unlike the individual alliance challenges, you don't need
to fly every airline in an alliance just one flight per alliance is enough.
</p>
<p>
Codeshare flights do not count, the operating carrier must be a member of the
alliance.
</p>
</Panel>
</template>
<style scoped>
.alliance-header {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.logo-row {
display: flex;
gap: 0.75rem;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.airline-label {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.25rem;
}
</style>
@@ -21,6 +21,12 @@ const continents = computed(() => props.continents.filter(c => c.internal_name !
const { entriesByDeparture, departureNames, completedCount, totalCount } = const { entriesByDeparture, departureNames, completedCount, totalCount } =
useDirectedContinentPairs(flights, continents) useDirectedContinentPairs(flights, continents)
// Split "Africa Asia" into ["Africa", "Asia"] for independent flex items
function splitLabel(label: string): [string, string] {
const [from, to] = label.split('→').map(part => part.trim())
return [from, to]
}
</script> </script>
<template> <template>
@@ -35,11 +41,16 @@ const { entriesByDeparture, departureNames, completedCount, totalCount } =
:rows="entriesByDeparture.get(dep) ?? []" :rows="entriesByDeparture.get(dep) ?? []"
:rowKey="entry => entry.key" :rowKey="entry => entry.key"
:hasItems="entry => entry.flights.length > 0" :hasItems="entry => entry.flights.length > 0"
labelWidth="18em" compactOnMobile
> >
<template #label="{ row: entry }"> <template #label="{ row: entry }">
<div class="pair-label"> <div class="pair-label">
<span class="pair-name">{{ entry.label }}</span> <span class="pair-part">{{ splitLabel(entry.label)[0] }}</span>
<span class="pair-divider">
<span class="divider-horizontal">{{ '\u2192\uFE0E' }}</span>
<span class="divider-vertical">{{ '\u2193\uFE0E' }}</span>
</span>
<span class="pair-part">{{ splitLabel(entry.label)[1] }}</span>
</div> </div>
</template> </template>
@@ -80,4 +91,55 @@ const { entriesByDeparture, departureNames, completedCount, totalCount } =
gap: 1rem; gap: 1rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.pair-label {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0 0.25rem;
}
.pair-part {
font-size: 0.9rem;
text-align: center;
white-space: nowrap;
}
.pair-divider {
font-size: 0.9rem;
opacity: 0.6;
flex-shrink: 0;
}
.divider-vertical {
display: none;
}
@container (max-width: 480px) {
.pair-label {
flex-direction: column;
gap: 0.1rem;
}
.pair-part {
font-size: 0.7rem;
white-space: normal;
}
.pair-divider {
font-size: 0.65rem;
line-height: 1;
}
.divider-horizontal {
display: none;
}
.divider-vertical {
display: inline;
}
}
</style> </style>
@@ -19,6 +19,12 @@ const flights = computed(() => props.flights)
const continents = computed(() => props.continents.filter(c => c.internal_name !== 'antarctica')) const continents = computed(() => props.continents.filter(c => c.internal_name !== 'antarctica'))
const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flights, continents) const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flights, continents)
// Split "AfricaAsia" into ["Africa", "Asia"] for independent flex items
function splitLabel(label: string): [string, string] {
const [from, to] = label.split('↔')
return [from, to]
}
</script> </script>
<template> <template>
@@ -32,11 +38,16 @@ const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flig
:rows="entries" :rows="entries"
:rowKey="entry => entry.key" :rowKey="entry => entry.key"
:hasItems="entry => entry.flights.length > 0" :hasItems="entry => entry.flights.length > 0"
labelWidth="18em" compactOnMobile
> >
<template #label="{ row: entry }"> <template #label="{ row: entry }">
<div class="pair-label"> <div class="pair-label">
<span class="pair-name">{{ entry.label }}</span> <span class="pair-part">{{ splitLabel(entry.label)[0] }}</span>
<span class="pair-divider">
<span class="divider-horizontal">{{ '\u2194\uFE0E' }}</span>
<span class="divider-vertical">{{ '\u2195\uFE0E' }}</span>
</span>
<span class="pair-part">{{ splitLabel(entry.label)[1] }}</span>
</div> </div>
</template> </template>
@@ -80,12 +91,51 @@ const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flig
.pair-label { .pair-label {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: center;
gap: 0.35rem;
padding: 0 0.25rem; padding: 0 0.25rem;
} }
.pair-name { .pair-part {
font-size: 0.9rem; font-size: 0.9rem;
text-align: center;
white-space: nowrap;
}
.pair-divider {
font-size: 0.9rem;
opacity: 0.6;
flex-shrink: 0;
}
.divider-vertical {
display: none;
}
@container (max-width: 480px) {
.pair-label {
flex-direction: column;
gap: 0.1rem;
}
.pair-part {
font-size: 0.7rem;
white-space: normal;
}
.pair-divider {
font-size: 0.65rem;
line-height: 1;
}
.divider-horizontal {
display: none;
}
.divider-vertical {
display: inline;
}
} }
</style> </style>
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, Flight, User, Airport } from '@/Types/types'
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
achievement: Achievement
user: User
followStatus: string
londonAirports: string[]
flights: Flight[]
}>()
interface AirportEntry {
code: string
airport: Airport | null
flights: Flight[]
}
const LONDON_AIRPORT_NAMES: Record<string, string> = {
LHR: 'Heathrow Airport',
LGW: 'Gatwick Airport',
STN: 'Stansted Airport',
LTN: 'Luton Airport',
LCY: 'City Airport',
SEN: 'Southend Airport',
}
const airports = computed<AirportEntry[]>(() => {
const entries = props.londonAirports.map(code => ({
code,
airport: null as Airport | null,
flights: [] as Flight[],
}))
const byCode = new Map(entries.map(e => [e.code, e]))
for (const flight of props.flights) {
const dep = flight.departure_airport
const arr = flight.arrival_airport
if (dep?.iata_code && byCode.has(dep.iata_code)) {
const entry = byCode.get(dep.iata_code)!
entry.airport ??= dep
entry.flights.push(flight)
}
if (arr?.iata_code && byCode.has(arr.iata_code)) {
const entry = byCode.get(arr.iata_code)!
entry.airport ??= arr
entry.flights.push(flight)
}
}
return entries
})
const rows = computed(() => airports.value.map(e => e.code))
function entryFor(code: string): AirportEntry {
return airports.value.find(e => e.code === code)!
}
function flightTitle(flight: Flight): string {
const name = flight.airline?.name ?? 'Unknown airline'
return `${name}: ${flight.departure_airport?.iata_code}${flight.arrival_airport?.iata_code}`
}
</script>
<template>
<!-- Airports table -->
<Panel>
<div class="table-toolbar">
<PanelHeader>London Airports</PanelHeader>
</div>
<BadgeTable
:rows="rows"
:rowKey="code => code"
:hasItems="code => entryFor(code).flights.length > 0"
labelWidth="14em"
>
<template #label="{ row: code }">
<div class="airport-label">
<span class="airport-code">{{ code }}</span>
<span>{{ LONDON_AIRPORT_NAMES[code] ?? code }}</span>
</div>
</template>
<template #items="{ row: code }">
<FlightBadge
v-for="flight in entryFor(code).flights"
:key="flight.id"
:title="flightTitle(flight)"
:flight="flight"
/>
</template>
</BadgeTable>
</Panel>
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly to all {{ londonAirports.length }} of
London's airports Heathrow, Gatwick, Stansted, Luton, London City, and Southend.
</p>
</Panel>
</template>
<style scoped>
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.airport-label {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.25rem;
}
.airport-code {
font-weight: 600;
opacity: 0.7;
min-width: 2.75rem;
}
</style>
+12 -1
View File
@@ -12,6 +12,7 @@ import {useFlights} from "@/Composables/useFlights";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue"; import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue"; import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue"; import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
@@ -24,8 +25,10 @@ const props = defineProps<{
regions: Region[] regions: Region[]
alliance: Alliance | null alliance: Alliance | null
airlines: Airline[] airlines: Airline[]
london_airports: string[]
continents: Continent[] continents: Continent[]
aircraft_families: Record<string, string[]> aircraft_families: Record<string, string[]>
majorAlliances: Alliance[]
achievementCount: number achievementCount: number
canView: boolean canView: boolean
}>() }>()
@@ -63,7 +66,7 @@ const unlocked = computed(() => {
<VAlert type="info" v-if="loggedInUser?.id !== user.id && loggedInUser"> <VAlert type="info" v-if="loggedInUser?.id !== user.id && loggedInUser">
You are viewing {{user.name}}'s progress in this achievement. If you would like to see your progress, please You are viewing {{user.name}}'s progress in this achievement. If you would like to see your progress, please
<Link :href="route('profile.achievement', {user: loggedInUser.name, achievement: achievement.internal_name})"><VBtn variant="flat">click here.</VBtn></Link> <Link :href="route('profile.achievement', {user: loggedInUser.name, achievement: achievement.internal_name})"><u>click here.</u></Link>
</VAlert> </VAlert>
@@ -116,6 +119,14 @@ const unlocked = computed(() => {
:alliance="alliance" :alliance="alliance"
:continents="continents" :continents="continents"
:families="aircraft_families" :families="aircraft_families"
:major-alliances="majorAlliances"
:london-airports="london_airports"
/>
<FollowingSelectBox
redirect-route="profile.achievement"
:route-params="{ achievement: achievement.internal_name }"
placeholder="View the Progress of Someone You Follow"
/> />
</div> </div>
</ProfileLayout> </ProfileLayout>
-1
View File
@@ -191,7 +191,6 @@ watch(() => form.flight_reason, (val) => console.log('flight_reason changed:', v
<v-select v-model="form.seat_type" label="Seat Type" :items="flight.seat_types" :error-messages="submitForm.errors.seat_type_id" /> <v-select v-model="form.seat_type" label="Seat Type" :items="flight.seat_types" :error-messages="submitForm.errors.seat_type_id" />
</v-col> </v-col>
<v-col cols="12" md="6"> <v-col cols="12" md="6">
{{console.log(form.flight_reason)}}
<v-select v-model="form.flight_reason" label="Reason" :items="flight.flight_reasons" :error-messages="submitForm.errors.flight_reason_id" /> <v-select v-model="form.flight_reason" label="Reason" :items="flight.flight_reasons" :error-messages="submitForm.errors.flight_reason_id" />
</v-col> </v-col>
</v-row> </v-row>
+3
View File
@@ -9,6 +9,7 @@ import MainLayout from "@/Layouts/MainLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue"; import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue"; import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import {useUpdateSetting} from "@/Composables/useUpdateSetting"; import {useUpdateSetting} from "@/Composables/useUpdateSetting";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
const {updateSetting} = useUpdateSetting() const {updateSetting} = useUpdateSetting()
@@ -121,6 +122,8 @@ const filteredUnlockedCount = computed(() =>
/> />
</div> </div>
</Panel> </Panel>
<FollowingSelectBox redirect-route="profile.achievements" placeholder="View The Achievements of Someone You Follow" />
</div> </div>
</ProfileLayout> </ProfileLayout>
</template> </template>
+10
View File
@@ -56,6 +56,8 @@ const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([]) const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([]) const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([]) const selectedAircraftModels = ref<number[]>([])
const selectedEngineTypes = ref<string[]>([])
const selectedEngineCounts = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([]) const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([]) const selectedOtherUsers = ref<string[]>([])
@@ -74,6 +76,8 @@ const activeFilterCount = computed(() =>
selectedSeatTypes.value.length + selectedSeatTypes.value.length +
selectedManufacturers.value.length + selectedManufacturers.value.length +
selectedAircraftModels.value.length + selectedAircraftModels.value.length +
selectedEngineTypes.value.length +
selectedEngineCounts.value.length +
selectedAirportRegions.value.length + selectedAirportRegions.value.length +
selectedOtherUsers.value.length selectedOtherUsers.value.length
) )
@@ -93,6 +97,8 @@ function onFiltersChange(filters: {
seatTypes: number[] seatTypes: number[]
manufacturers: string[] manufacturers: string[]
aircraftModels: number[] aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[] airportRegions: number[]
otherUsers: string[] otherUsers: string[]
}) { }) {
@@ -111,6 +117,8 @@ function onFiltersChange(filters: {
selectedSeatTypes.value = filters.seatTypes selectedSeatTypes.value = filters.seatTypes
selectedManufacturers.value = filters.manufacturers selectedManufacturers.value = filters.manufacturers
selectedAircraftModels.value = filters.aircraftModels selectedAircraftModels.value = filters.aircraftModels
selectedEngineTypes.value = filters.engineTypes
selectedEngineCounts.value = filters.engineCounts
selectedAirportRegions.value = filters.airportRegions selectedAirportRegions.value = filters.airportRegions
selectedOtherUsers.value = filters.otherUsers selectedOtherUsers.value = filters.otherUsers
} }
@@ -141,6 +149,8 @@ function matchesFilters(f: Flight): boolean {
if (selectedSeatTypes.value.length && !selectedSeatTypes.value.includes(f.seat_type?.id ?? -1)) return false if (selectedSeatTypes.value.length && !selectedSeatTypes.value.includes(f.seat_type?.id ?? -1)) return false
if (selectedManufacturers.value.length && !selectedManufacturers.value.includes(f.aircraft?.manufacturer_code ?? '')) return false if (selectedManufacturers.value.length && !selectedManufacturers.value.includes(f.aircraft?.manufacturer_code ?? '')) return false
if (selectedAircraftModels.value.length && !selectedAircraftModels.value.includes(f.aircraft?.id ?? -1)) return false if (selectedAircraftModels.value.length && !selectedAircraftModels.value.includes(f.aircraft?.id ?? -1)) return false
if (selectedEngineTypes.value.length && !selectedEngineTypes.value.includes(f.aircraft?.engine_type ?? '')) return false
if (selectedEngineCounts.value.length && !selectedEngineCounts.value.includes(f.aircraft?.engine_count ?? -1)) return false
if (selectedAirportRegions.value.length) { if (selectedAirportRegions.value.length) {
const depRegion = f.departure_airport.region?.id const depRegion = f.departure_airport.region?.id
const arrRegion = f.arrival_airport.region?.id const arrRegion = f.arrival_airport.region?.id
+12
View File
@@ -30,6 +30,8 @@ export interface UserSettings {
show_map_legend: boolean show_map_legend: boolean
hide_impossible_achievements: boolean hide_impossible_achievements: boolean
hide_map_filters: boolean hide_map_filters: boolean
time_format: "ampm" | "24hr"
date_format: string
} }
@@ -99,6 +101,7 @@ export type SharedProps = import('@inertiajs/core').PageProps & {
roles: string[]; roles: string[];
permissions: string[]; permissions: string[];
apiToken: string | null; apiToken: string | null;
following: string[];
}, },
flash: { flash: {
success?: string; success?: string;
@@ -326,6 +329,15 @@ export interface MissingLivery {
clipboard_text: string; clipboard_text: string;
} }
export interface MissingAirline{
code: string;
flights: {
flight_number: string;
link: string;
id: number;
}[]
}
declare module '@inertiajs/vue3' { declare module '@inertiajs/vue3' {
interface PageProps extends SharedProps {} interface PageProps extends SharedProps {}
} }
+6 -1
View File
@@ -40,6 +40,10 @@ use Inertia\Inertia;
Route::get('/', [AdminController::class, 'dashboard'])->name('dashboard'); Route::get('/', [AdminController::class, 'dashboard'])->name('dashboard');
Route::get('/missing-liveries', [AdminController::class, 'reconcileMissingLiveries'])->name('reconcile-missing-liveries')->middleware('permission:reconcile_missing_liveries'); Route::get('/missing-liveries', [AdminController::class, 'reconcileMissingLiveries'])->name('reconcile-missing-liveries')->middleware('permission:reconcile_missing_liveries');
Route::post('/ignore-missing-livery', [AdminController::class, 'ignoreMissingLivery'])->name('ignore-missing-livery'); Route::post('/ignore-missing-livery', [AdminController::class, 'ignoreMissingLivery'])->name('ignore-missing-livery');
Route::get('/missing-airlines', [AdminController::class, 'reconcileMissingAirlines'])->name('reconcile-missing-airlines')->middleware('permission:reconcile_missing_liveries');
Route::post('/ignore-missing-airline', [AdminController::class, 'ignoreMissingAirline'])->name('admin.ignore-missing-airline');
}); });
@@ -101,10 +105,11 @@ Route::middleware(['auth'])->group(function () {
//Search Routes //Search Routes
Route::get('/search/airlines', [SearchController::class, 'airlines'])->name('search.airlines'); Route::get('/search/airlines', [SearchController::class, 'airlines'])->name('search.airlines');
Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft'); Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft');
Route::get('/search/airports', [SearchController::class, 'airports'])->name('search.airports'); Route::get('/search/airports', [SearchController::class, 'airports'])->name('search.airports');
Route::get('/search/users', [SearchController::class, 'users'])->name('search.users');
Route::get('/u/{user}', [UserProfileController::class, 'view'])->name('profile.view'); Route::get('/u/{user}', [UserProfileController::class, 'view'])->name('profile.view');
Route::get('/u/{user}/map', [UserProfileController::class, 'map'])->name('profile.map'); Route::get('/u/{user}/map', [UserProfileController::class, 'map'])->name('profile.map');