Added Notifications

This commit is contained in:
2026-05-16 23:48:18 +10:00
parent 69d72e0912
commit 1d5b9f340f
61 changed files with 4204 additions and 182 deletions
+59 -10
View File
@@ -3,6 +3,9 @@
namespace App\Http\Controllers;
use App\Models\Achievement;
use App\Models\Aircraft;
use App\Models\Alliance;
use App\Models\Continent;
use App\Models\Country;
use App\Models\User;
use Illuminate\Http\Request;
@@ -15,7 +18,7 @@ class AchievementController extends Controller
$achievements = Achievement::with(['category', 'difficulty'])
->get()
->groupBy(fn(Achievement $a) => $a->category->name)
->map(fn($group) => $group->sortBy('id')->values());
->map(fn($group) => $group->sortBy('sort_order')->values());
$userAchievements = $user->achievements()
->with('achievement')
@@ -29,26 +32,72 @@ class AchievementController extends Controller
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
'achievements' => $achievements,
'userAchievements' => $userAchievements,
'loggedInUser' => auth()->user(),
]);
}
function getRegionsByCountryCode(string $countryCode)
{
return Country::whereCode($countryCode)
->first()
->regions()
->orderBy('name')
->get()
->toArray();
}
public function specific(User $user, Achievement $achievement)
{
$regions = match($achievement->internal_name){
'fun_challenges.australian_states' => Country::where('code', 'AU')->first()->regions->toArray(),
'fun_challenges.chinese_provinces' => Country::where('code', 'CN')->first()->regions->toArray(),
'fun_challenges.canadian_provinces' => Country::where('code', 'CA')->first()->regions->toArray(),
'fun_challenges.us_states' => Country::where('code', 'US')->first()->regions->toArray(),
'fun_challenges.australian_states' => $this->getRegionsByCountryCode('AU'),
'fun_challenges.chinese_provinces' => $this->getRegionsByCountryCode('CN'),
'fun_challenges.canadian_provinces' => $this->getRegionsByCountryCode('CA'),
'fun_challenges.brazilian_states' => $this->getRegionsByCountryCode('BR'),
'fun_challenges.us_states' => $this->getRegionsByCountryCode('US'),
default => [],
};
$allianceInternalName = match($achievement->internal_name){
'airlines_alliances.all_star_alliance' => 'star_alliance',
'airlines_alliances.all_oneworld' => 'oneworld',
'airlines_alliances.all_skyteam' => 'skyteam',
'airlines_alliances.all_vanilla_alliance' => 'vanilla_alliance',
default => null,
};
$continents = match($achievement->internal_name){
'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(),
default => [],
};
$alliance = null;
$airlines = [];
if ($allianceInternalName) {
$alliance = Alliance::where('internal_name', $allianceInternalName)
->with('airlines')
->firstOrFail();
$airlines = $alliance->airlines()->with('country')->orderBy('name')->get();
}
$aircraftFamilies = match($achievement->internal_name){
'aircraft.all_boeing_7x7' => Aircraft::BOEING_FAMILIES,
'aircraft.all_airbus_a3xx' => Aircraft::AIRBUS_FAMILIES,
default => [],
};
return Inertia::render('Profile/UserAchievement', [
'user' => $user,
'achievement' => $achievement,
'user' => $user,
'achievement' => $achievement,
'loggedInUser' => auth()->user(),
'userAchievement' => $user->achievements()->where('achievement_id', $achievement->id)->first(),
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
'flight_api_url' => FlightProfileController::getUserFlightApiURL($user),
'regions' => $regions,
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
'flight_api_url' => FlightProfileController::getUserFlightApiURL($user),
'regions' => $regions,
'alliance' => $alliance,
'airlines' => $airlines,
'continents' => $continents,
'aircraft_families' => $aircraftFamilies,
]);
}
@@ -7,6 +7,7 @@ use App\Models\User;
use App\Models\UserFlight;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserApiController extends ApiController
{
@@ -47,7 +48,7 @@ class UserApiController extends ApiController
]);
}
public function flights(string $username): JsonResponse
public function flights(string $username, Request $request): JsonResponse
{
$user = User::where('name', 'ilike', $username)->first();
@@ -55,6 +56,6 @@ class UserApiController extends ApiController
return response()->json(['message' => 'User not found'], 404);
}
return response()->json($user->FlightController()->flights());
return response()->json($user->FlightController()->flights($request));
}
}
@@ -22,6 +22,7 @@ class FlightProfileController extends Controller
'selectedFlightId' => $selectedFlightId,
'flight_api_url' => self::getUserFlightApiURL($user),
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
'flightCount' => $user->departedFlights()->count(),
];
}
@@ -15,7 +15,8 @@ class UserFlightController extends Controller
$this->user = $user;
}
public function flights(){
public function flights(?Request $request = null)
{
return UserFlight::where('user_id', $this->user->id)
->with([
'departureAirport.region.country',
@@ -30,6 +31,7 @@ class UserFlightController extends Controller
'flightClass',
'crewType'
])
->when($request?->boolean('departed_only'), fn($q) => $q->where('departure_date', '<=', now('UTC')))
->orderBy('departure_date', 'desc')
->get();
}
+1
View File
@@ -40,6 +40,7 @@ class Achievement extends Model
'achievement_difficulty_id',
'threshold',
'has_page',
'sort_order',
];
protected $casts = [
+23 -3
View File
@@ -26,9 +26,29 @@ class Aircraft extends Model
'display_name_short'
];
const array IATA_ALIAS_MAP = [
'7S8' => '73H',
'7S9' => '73J'
public const array BOEING_FAMILIES = [
'707' => ['B701', 'B703', 'B720'],
'717' => ['B712', 'B717'],
'727' => ['B721', 'B722', 'B727'],
'737' => ['B731', 'B732', 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', 'B37M', 'B38M', 'B39M'],
'747' => ['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S'],
'757' => ['B752', 'B753', 'B757'],
'767' => ['B762', 'B763', 'B764', 'B767'],
'777' => ['B772', 'B773', 'B77L', 'B77W', 'B778', 'B779'],
'787' => ['B788', 'B789', 'B78X'],
];
public const array AIRBUS_FAMILIES = [
'A300' => ['A30B', 'A300', 'A306'],
'A310' => ['A310', 'A312', 'A313'],
'A318' => ['A318'],
'A319' => ['A319', 'A31X'],
'A320' => ['A320', 'A20N'],
'A321' => ['A321', 'A21N'],
'A330' => ['A330', 'A332', 'A333', 'A338', 'A339'],
'A340' => ['A340', 'A342', 'A343', 'A345', 'A346'],
'A350' => ['A350', 'A358', 'A359', 'A35K'],
'A380' => ['A380', 'A388'],
];
protected function displayName() : Attribute{
+8
View File
@@ -55,6 +55,14 @@ class User extends Authenticatable
return $this->hasMany(UserFlight::class);
}
public function departedFlights() : HasMany {
return $this->flights()->where('departure_date', '<=', now('UTC'));
}
public function upcomingFlights() : HasMany {
return $this->flights()->where('departure_date', '>=', now('UTC'));
}
public function ImportedFlights(): HasMany
{
return $this->hasMany(ImportedFlight::class);
@@ -59,7 +59,7 @@ class AchievementService
'arrivalAirport.region',
'departureAirport.region.continent',
'arrivalAirport.region.continent',
])->get();
])->where('departure_date', '<=', now('UTC'))->get();
foreach ($this->checkers as $checkerClass) {
$checker = new $checkerClass($this);
@@ -2,37 +2,13 @@
namespace App\Services\Achievements\Checkers;
use App\Models\Aircraft;
use App\Models\User;
use App\Models\UserFlight;
use Illuminate\Support\Collection;
class AircraftChecker extends BaseChecker
{
private const array BOEING_FAMILIES = [
'707' => ['B701', 'B703', 'B720'],
'717' => ['B712', 'B717'],
'727' => ['B721', 'B722', 'B727'],
'737' => ['B731', 'B732', 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', 'B37M', 'B38M', 'B39M'],
'747' => ['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S'],
'757' => ['B752', 'B753', 'B757'],
'767' => ['B762', 'B763', 'B764', 'B767'],
'777' => ['B772', 'B773', 'B77L', 'B77W', 'B778', 'B779'],
'787' => ['B788', 'B789', 'B78X'],
];
private const array AIRBUS_FAMILIES = [
'A300' => ['A30B', 'A300', 'A306'],
'A310' => ['A310', 'A312', 'A313'],
'A318' => ['A318'],
'A319' => ['A319', 'A31X'],
'A320' => ['A320', 'A20N'],
'A321' => ['A321', 'A21N'],
'A330' => ['A330', 'A332', 'A333', 'A338', 'A339'],
'A340' => ['A340', 'A342', 'A343', 'A345', 'A346'],
'A350' => ['A350', 'A358', 'A359', 'A35K'],
'A380' => ['A380', 'A388'],
];
private const array DOUBLE_DECKER_DESIGNATORS = [
// A380
'A380', 'A388',
@@ -121,7 +97,7 @@ class AircraftChecker extends BaseChecker
// --- Boeing 7x7 families ---
$flownBoeingFamilies = collect(self::BOEING_FAMILIES)
$flownBoeingFamilies = collect(Aircraft::BOEING_FAMILIES)
->filter(fn($designators) =>
$flightsWithAircraft->contains(
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
@@ -133,7 +109,7 @@ class AircraftChecker extends BaseChecker
// --- Airbus A3xx families ---
$flownAirbusFamilie = collect(self::AIRBUS_FAMILIES)
$flownAirbusFamilie = collect(Aircraft::AIRBUS_FAMILIES)
->filter(fn($designators) =>
$flightsWithAircraft->contains(
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
@@ -76,6 +76,13 @@ class CountriesAndContinentsChecker extends BaseChecker
$dep = $flight->departureAirport->region->continent->internal_name;
$arr = $flight->arrivalAirport->region->continent->internal_name;
if (!in_array($dep, self::INHABITED_CONTINENTS) || !in_array($arr, self::INHABITED_CONTINENTS)) continue;
if ($dep === $arr) {
$depCountry = $flight->departureAirport->region->country_id;
$arrCountry = $flight->arrivalAirport->region->country_id;
if ($depCountry === $arrCountry) continue;
}
// Directed route key e.g. "europe→asia"
$directedRoutes->push("{$dep}{$arr}");
@@ -70,6 +70,11 @@ class GeneralFlyingChecker extends BaseChecker
// --- Progressive achievements ---
$totalDistance = $flights->sum('distance');
$this->awardProgress((int) $totalDistance, 'general_flying.circumference_of_the_earth');
$this->awardProgress((int) $totalDistance, 'general_flying.to_the_moon');
$this->awardProgress($count,'general_flying.10_flights');
$this->awardProgress($count,'general_flying.50_flights');
$this->awardProgress($count,'general_flying.100_flights');
@@ -0,0 +1,102 @@
<?php
use App\Models\Achievement;
use App\Models\AchievementCategory;
use App\Models\AchievementDifficulty;
use App\Models\Airline;
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
{
Achievement::create([
'name' => 'Circumnavigator',
'internal_name' => 'general_flying.circumference_of_the_earth',
'short_description' => 'Fly the same distance as the circumference of the Earth at the equator!',
'icon' => 'standard_achievement.png',
'progressive' => true,
'long_description' => '',
'achievement_category_id' => AchievementCategory::where('internal_name', 'general_flying')->first()->id,
'achievement_difficulty_id' => AchievementDifficulty::where('internal_name', 'moderate')->first()->id,
'threshold' => 40075,
'has_page' => false,
]);
Achievement::create([
'name' => 'Fly Me to The Moon',
'internal_name' => 'general_flying.to_the_moon',
'short_description' => 'Fly the same distance as the Earth to the Moon!',
'icon' => 'standard_achievement.png',
'long_description' => '',
'progressive' => true,
'threshold' => 384400,
'achievement_category_id' => AchievementCategory::where('internal_name', 'general_flying')->first()->id,
'achievement_difficulty_id' => AchievementDifficulty::where('internal_name', 'hard')->first()->id,
'has_page' => false,
]);
Achievement::whereInternalName('aircraft.all_boeing_7x7')->update(['has_page' => true]);
Achievement::whereInternalName('aircraft.all_airbus_a3xx')->update(['has_page' => true]);
Airline::whereInternalName('south-africa-airways')->update(['name' => 'South African Airways']);
Schema::table('achievements', function (Blueprint $table) {
$table->unsignedInteger('sort_order')->nullable()->after('id');
});
// Seed sort_order from current id order, scoped per category
$achievements = DB::table('achievements')
->orderBy('achievement_category_id')
->orderBy('id')
->get();
$position = 1;
$currentCategory = null;
foreach ($achievements as $achievement) {
if ($achievement->achievement_category_id !== $currentCategory) {
$position = 1;
$currentCategory = $achievement->achievement_category_id;
}
DB::table('achievements')
->where('id', $achievement->id)
->update(['sort_order' => $position++]);
}
// Move "Four on the Floor" (id 30) after "Triple Threat" (id 34)
// within the aircraft category — swap their sort_order values
$triEngine = DB::table('achievements')->where('internal_name', 'aircraft.tri_engine')->first();
$quadEngine = DB::table('achievements')->where('internal_name', 'aircraft.quad_engine')->first();
DB::table('achievements')->where('internal_name', 'aircraft.quad_engine')
->update(['sort_order' => $triEngine->sort_order + 1]);
// Shift everything between them up by 1 to make room
DB::table('achievements')
->where('achievement_category_id', $quadEngine->achievement_category_id)
->where('sort_order', '>=', $triEngine->sort_order + 1)
->where('internal_name', '!=', 'aircraft.quad_engine')
->increment('sort_order');
$users = User::all();
foreach ($users as $user) {
$user->calculateAchievements();
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};
+4
View File
@@ -103,12 +103,16 @@ body {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #778;
position: relative;
overflow: hidden;
}
.class-premium-global {
background: rgba(75, 32, 137, 0.35);
border: 1px solid rgba(180, 130, 255, 0.25);
color: #c49dff;
position: relative;
overflow: hidden;
}
.class-premium_economy-global {
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import FormattedNumber from "@/Components/FormattedNumber.vue";
const props = withDefaults(defineProps<{
value: number
unit?: 'km' | 'mi' | 'nm'
showUnits?: boolean
includeSpace?: boolean
}>(), {
showUnits: true,
unit: 'km',
includeSpace: false,
})
const CONVERSIONS: Record<string, number> = {
km: 1,
mi: 0.621371,
nm: 0.539957,
}
const LABELS: Record<string, string> = {
km: 'km',
mi: 'mi',
nm: 'nm',
}
const unit = computed(() => props.unit ?? 'km')
const converted = computed(() => props.value * CONVERSIONS[unit.value])
</script>
<template>
<FormattedNumber :value="converted" />{{includeSpace ? ' ' : ''}}{{ showUnits ? LABELS[unit] : '' }}
</template>
<style scoped>
</style>
@@ -5,6 +5,13 @@ import {computed} from "vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import {Link} from '@inertiajs/vue3'
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
import Distance from "@/Components/Distance.vue";
const distanceAchievements = [
'general_flying.circumference_of_the_earth',
'general_flying.to_the_moon',
];
const props = defineProps<{
achievement: Achievement
@@ -85,13 +92,11 @@ const difficultyVariant = computed(() => {
</div>
<p class="achievement-description">{{ achievement.short_description }}</p>
<Link v-if="achievement.has_page && user" :href="route('profile.achievement', { user: user.name, achievement: achievement.internal_name })">
View Details
</Link>
<template v-if="achievement.progressive && progress">
<div class="progress-label">
<span>{{ Math.min(progress.current, progress.threshold) }} / {{ progress.threshold }}</span>
<span><distance :showUnits="false" :value="Math.min(progress.current, progress.threshold)" /> / <distance :value="progress.threshold" :showUnits="distanceAchievements.includes(achievement.internal_name)" /></span>
<span>{{ progress.percentage }}%</span>
</div>
<v-progress-linear
@@ -102,11 +107,17 @@ const difficultyVariant = computed(() => {
bg-color="rgba(255,255,255,0.1)"
/>
</template>
</div>
</div>
<br/>
<ButtonLink
variant="outlined"
label="View Details"
icon="mdi-magnify"
v-if="achievement.has_page && user" :href="route('profile.achievement', { user: user.name, achievement: achievement.internal_name })" />
</v-card-text>
</v-card>
</template>
@@ -115,19 +126,17 @@ const difficultyVariant = computed(() => {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.achievement-card.locked {
.achievement-card.locked .achievement-inner {
opacity: 0.45;
}
.achievement-card:hover {
opacity: 1;
transform: translateY(-1px);
}
.achievement-card:hover .achievement-inner,
.achievement-inner {
display: flex;
gap: 1rem;
align-items: flex-start;
opacity: 1;
}
.achievement-icon-wrap {
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { Flight, Airline } from '@/Types/types'
import type { CodeType } from '@/Composables/useAlphabetAirlines'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
const props = defineProps<{
letters: string[]
flightsByLetter: Record<string, Flight[]>
codeType: CodeType
selectedYear: number | null
}>()
interface AirlineEntry {
airline: Airline
code: string
firstYear: number
}
function getCode(airline: Airline): string | null {
const raw = props.codeType === 'iata' ? airline.IATA_code : airline.ICAO_code
return raw?.trim().toUpperCase() ?? null
}
function airlineEntriesForLetter(letter: string): AirlineEntry[] {
const flights = props.flightsByLetter[letter] ?? []
const seen = new Map<string, AirlineEntry>()
for (const flight of flights) {
const airline = flight.airline
if (!airline) continue
const code = getCode(airline)
if (!code?.startsWith(letter)) continue
const year = new Date(flight.departure_date).getFullYear()
const existing = seen.get(code)
if (!existing || year < existing.firstYear) {
seen.set(code, { airline, code, firstYear: year })
}
}
return [...seen.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([, entry]) => entry)
}
function isHighlighted({ firstYear }: AirlineEntry): boolean {
return props.selectedYear !== null && firstYear === props.selectedYear
}
function toBBCode(): string {
return props.letters
.map(letter => {
const entries = airlineEntriesForLetter(letter)
if (!entries.length) return letter
return entries
.map(entry =>
isHighlighted(entry)
? `[b][color=#00BF00]${entry.code}[/color][/b]`
: entry.code
)
.join(', ')
})
.join('\n')
}
defineExpose({ toBBCode })
</script>
<template>
<BadgeTable
:rows="letters"
:rowKey="letter => letter"
:hasItems="letter => !!flightsByLetter[letter]?.length"
labelWidth="4em"
>
<template #label="{ row: letter }">
<div
style="width:100%;display:flex;justify-content:center;align-items:center"
:class="flightsByLetter[letter]?.length ? 'visited' : 'unvisited'"
>
{{ letter }}
</div>
</template>
<template #items="{ row: letter }">
<div
v-for="entry in airlineEntriesForLetter(letter)"
:key="entry.airline.IATA_code!"
>
<InlineBadge style="align-items:center;gap:0.2em" :variant="isHighlighted(entry) ? 'business' : undefined">
<AirlineLogo :airline="entry.airline" /> {{ entry.code }}
</InlineBadge>
</div>
</template>
</BadgeTable>
</template>
<style scoped>
.visited {
font-weight: 700;
color: var(--accent);
}
.unvisited {
color: var(--muted);
}
</style>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Achievement, Airline, 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 InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
import AirlineLogo from '@/Components/FlightsGoneBy/AirlineLogo.vue'
import AllianceLogo from '@/Components/FlightsGoneBy/AllianceLogo.vue'
import FlightBadge from "@/Components/FlightsGoneBy/FlightBadge.vue";
interface AirlineEntry {
airline: Airline
flights: Flight[]
}
const props = defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
alliance: Alliance
airlines: Airline[]
flights: Flight[]
}>()
const flightsByAirline = computed(() => {
const allianceAirlineIds = new Set(props.airlines.map(a => a.id))
const map = new Map<string, AirlineEntry>()
// Pre-populate every alliance airline so unflown ones still appear
for (const airline of props.airlines) {
map.set(rowKey(airline), { airline, flights: [] })
}
for (const flight of props.flights) {
const airline = flight.airline
if (!airline || !allianceAirlineIds.has(airline.id)) continue
map.get(rowKey(airline))?.flights.push(flight)
}
return map
})
function rowKey(airline: Airline): string {
return airline.IATA_code ?? airline.internal_name
}
const rows = computed(() => [...flightsByAirline.value.keys()])
function entryFor(key: string): AirlineEntry {
return flightsByAirline.value.get(key)!
}
</script>
<template>
<!-- Header -->
<Panel>
<div class="alliance-header">
<AllianceLogo :alliance="alliance" size="56" />
<div>
<PanelHeader centered>{{ alliance.name }}</PanelHeader>
<PanelSubHeader centered>
<slot />
</PanelSubHeader>
</div>
</div>
</Panel>
<!-- Airlines table -->
<Panel>
<div class="table-toolbar">
<PanelHeader>Airlines</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" >
<AirlineLogo :airline="entryFor(key).airline" size="24" />
<span>{{ entryFor(key).airline.name }}</span>
</div>
</template>
<template #items="{ row: key }">
<FlightBadge
v-for="flight in entryFor(key).flights"
:key="flight.id"
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
:flight="flight" />
</template>
</BadgeTable>
</Panel>
<!-- Slot for alliance-specific panels -->
<slot name="extra" />
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly with every current member airline of
<strong>{{ alliance.name }}</strong>. Alliance membership changes over time, so the
required airlines reflect the current roster.
</p>
<p>
Codeshare flights do not count, the operating carrier must be a member of {{alliance.name}}.
</p>
</Panel>
</template>
<style scoped>
.alliance-header {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.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>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import {Airport, Flight} from '@/Types/types'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
import AirportToolTip from '@/Components/FlightsGoneBy/AirportToolTip.vue'
type CodeType = 'iata' | 'icao'
const props = defineProps<{
letters: string[]
flightsByLetter: Record<string, Flight[]>
codeType: CodeType
selectedYear: number | null
}>()
function getCode(airport: Airport): string | null {
const raw = props.codeType === 'iata' ? airport.iata_code : airport.icao_code
return raw?.trim().toUpperCase() ?? null
}
interface AirportEntry {
airport: Airport
firstYear: number
}
function airportEntriesForLetter(letter: string): AirportEntry[] {
const flights = props.flightsByLetter[letter] ?? []
const seen = new Map<string, AirportEntry>()
for (const flight of flights) {
const year = new Date(flight.departure_date).getFullYear()
for (const airport of [flight.departure_airport, flight.arrival_airport]) {
const code = getCode(airport)
if (!code?.startsWith(letter)) continue
const existing = seen.get(code)
if (!existing || year < existing.firstYear) {
seen.set(code, { airport, firstYear: year })
}
}
}
return [...seen.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([, entry]) => entry)
}
function isHighlighted({ firstYear }: AirportEntry): boolean {
return props.selectedYear !== null && firstYear === props.selectedYear
}
function toBBCode(): string {
return props.letters
.map(letter => {
const entries = airportEntriesForLetter(letter)
if (!entries.length) return letter
return entries
.map(entry => {
const code = getCode(entry.airport)!
return isHighlighted(entry) ? `[b][color=#00BF00]${code}[/color][/b]` : code
})
.join(', ')
})
.join('\n')
}
defineExpose({ toBBCode })
</script>
<template>
<BadgeTable
:rows="letters"
:rowKey="letter => letter"
:hasItems="letter => !!flightsByLetter[letter]?.length"
labelWidth="4em"
>
<template #label="{ row: letter }">
<div style="width:100%;display:flex;justify-content: center; align-items: center" :class="flightsByLetter[letter]?.length ? 'visited' : 'unvisited'">
{{ letter }}
</div>
</template>
<template #items="{ row: letter }">
<AirportToolTip
v-for="entry in airportEntriesForLetter(letter)"
:key="getCode(entry.airport)!"
:airport="entry.airport"
>
<InlineBadge :variant="isHighlighted(entry) ? 'business' : undefined">
{{ getCode(entry.airport) }}
</InlineBadge>
</AirportToolTip>
</template>
</BadgeTable>
</template>
<style scoped>
.visited {
font-weight: 700;
color: var(--accent);
}
.unvisited {
color: var(--muted);
}
.airport-count {
font-size: 0.7rem;
font-weight: 400;
color: var(--muted);
margin-left: 0.2em;
}
</style>
@@ -3,6 +3,7 @@ import { Flight } from "@/Types/types";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
import Distance from "@/Components/Distance.vue";
defineProps<{
flight: Flight
@@ -70,7 +71,7 @@ defineProps<{
<span class="pass-stat-divider" v-if="flight.duration_display && flight.distance">·</span>
<span v-if="flight.distance" class="pass-stat">
<span class="pass-stat-label">DISTANCE</span>
<span class="pass-stat-value">{{ Math.round(flight.distance).toLocaleString() }} km</span>
<span class="pass-stat-value"><Distance :value="Math.round(flight.distance)" /></span>
</span>
</div>
</div>
@@ -80,16 +81,6 @@ defineProps<{
<style scoped>
.feed-boarding-pass{
max-width: 600px;
margin: 1em auto;
}
@media (max-width: 1200px) {
.boarding-pass{
margin: 0;
}
}
.pass-stats-row {
display: flex;
@@ -0,0 +1,31 @@
<script setup lang="ts">
import {Link} from "@inertiajs/vue3";
type Method = 'get' | 'post' | 'put' | 'patch' | 'delete';
defineProps<{
href: string | {
url: string;
method: Method;
},
label: string;
icon?: string;
variant?: "flat" | "text" | "elevated" | "outlined" | "plain" | "tonal" | undefined
}>()
</script>
<template>
<Link :href="href">
<v-btn
style="width:100%;"
:prepend-icon="icon"
:variant="variant"
>
{{ label }}
</v-btn>
</Link>
</template>
<style scoped>
a{
width: 100%;
display: block;
}
</style>
@@ -13,6 +13,7 @@ import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import CrewTooltip from "@/Components/FlightsGoneBy/CrewTooltip.vue";
import {Link, router} from "@inertiajs/vue3";
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue";
import Distance from "@/Components/Distance.vue";
const props = defineProps<{
flightStats: FlightStats
@@ -238,7 +239,7 @@ watch(
<td class="v-data-table__td">
<span class="mono-tag distance-cell">
{{ (item as Flight).distance ? Math.round((item as Flight).distance).toLocaleString() + ' km' : '' }}
<Distance :value="Math.round((item as Flight).distance)" />
</span>
</td>
@@ -10,7 +10,7 @@ const props = defineProps<{
<template>
<div class="flight-booked">
<BoardingPass :class="`feed-boarding-pass`" :flight="flight" />
<BoardingPass style="max-width:90%; margin: 0 auto" :class="`feed-boarding-pass`" :flight="flight" />
</div>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import {Flight} from "@/Types/types";
defineProps<{
flight: Flight
}>()
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import FlightToolTip from "@/Components/FlightsGoneBy/FlightToolTip.vue";
</script>
<template>
<FlightToolTip
:flight="flight"
>
<InlineBadge class="flight-badge">
<AirlineLogo hideTooltip :airline="flight.airline" />
{{ flight.flight_number || `${flight.departure_airport.display_code}-${flight.arrival_airport.display_code}` }}
</InlineBadge>
</FlightToolTip>
</template>
<style scoped>
.flight-badge {
display: inline-flex;
align-items: center;
gap: 0.25em;
flex-shrink: 0;
}
</style>
@@ -471,6 +471,7 @@ export default defineComponent({
map = new maplibregl.Map({
container: mapContainer.value!,
cooperativeGestures: true,
attributionControl: false,
style: {
version: 8,
sources: {
@@ -483,7 +484,6 @@ export default defineComponent({
'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
maxzoom: 19,
},
},
@@ -1,49 +1,47 @@
<script setup lang="ts">
import {Flight} from "@/Types/types";
import FlightToolTip from "@/Components/FlightsGoneBy/FlightToolTip.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import { Flight } from '@/Types/types'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import FlightToolTip from '@/Components/FlightsGoneBy/FlightToolTip.vue'
import AirlineLogo from '@/Components/FlightsGoneBy/AirlineLogo.vue'
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
import {computed} from "vue";
import FlightBadge from "@/Components/FlightsGoneBy/FlightBadge.vue";
defineProps<{
const props = defineProps<{
regionCodes: string[]
flightsByRegion: Record<string, Flight[]>
regionNames?: Record<string, string>
}>()
const sortedRegionCodes = computed(() =>
[...props.regionCodes].sort((a, b) => {
const nameA = props.regionNames?.[a] ?? a
const nameB = props.regionNames?.[b] ?? b
return nameA.localeCompare(nameB)
})
)
</script>
<template>
<table>
<tr v-for="code in regionCodes" :key="code">
<td>{{ regionNames?.[code] ?? code }}</td>
<td>
<template v-if="flightsByRegion[code]?.length">
<span style="display:inline-flex; align-items:center; gap:0.25em; flex-wrap:wrap;">
<FlightToolTip
v-for="(flight, index) in flightsByRegion[code].slice(0, 5)"
:key="flight.id"
:flight="flight"
>
<InlineBadge style="display:inline-flex; align-items:center; gap:0.25em;">
<AirlineLogo hideTooltip :airline="flight.airline" />
{{ flight.flight_number || `${flight.departure_airport.display_code}-${flight.arrival_airport.display_code}` }}
</InlineBadge>
</FlightToolTip>
</span>
</template>
<template v-else>
</template>
</td>
</tr>
</table>
<BadgeTable
:rows="sortedRegionCodes"
:rowKey="code => code"
:hasItems="code => !!flightsByRegion[code]?.length"
>
<template #label="{ row: code }">
{{ regionNames?.[code] ?? code }}
</template>
<template #items="{ row: code }">
<FlightBadge
v-for="flight in flightsByRegion[code]"
:key="flight.id"
:flight="flight"
/>
</template>
</BadgeTable>
</template>
<style scoped>
table {
border-spacing: 0;
width: 100%;
}
table td {
border: solid 1px;
padding: 0.5em;
}
</style>
@@ -35,18 +35,13 @@
<div class="stat">
<template v-if="totalDistanceKm">
<div class="stat-primary">
<span class="stat-num">{{ totalDistanceKm.toLocaleString() }}</span>
<span class="unit">km</span>
<span class="stat-num"><Distance includeSpace :value="totalDistanceKm" /></span>
</div>
<div class="stat-sub">{{ totalDistanceMi.toLocaleString() }} miles</div>
</template>
<template v-if="upcomingDistanceKm">
<div :class="totalDistanceKm ? 'stat-upcoming' : 'stat-primary'">
<span :class="totalDistanceKm ? 'stat-upcoming-num' : 'stat-num'">{{ upcomingDistanceKm.toLocaleString() }}</span>
<span :class="totalDistanceKm ? 'stat-upcoming-lbl' : 'unit'">{{ totalDistanceKm ? 'km upcoming' : 'km' }}</span>
<span v-if="!totalDistanceKm" class="upcoming-badge">upcoming</span>
<span :class="totalDistanceKm ? 'stat-upcoming-num' : 'stat-num'"><Distance includeSpace :value="upcomingDistanceKm"/></span>
</div>
<div v-if="!totalDistanceKm" class="stat-sub">{{ upcomingDistanceMi.toLocaleString() }} miles</div>
</template>
</div>
@@ -105,6 +100,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Flight } from '@/Types/types'
import Distance from "@/Components/Distance.vue";
const props = defineProps<{
flights: Flight[]
@@ -117,10 +113,6 @@ const totalDistanceKm = computed(() =>
Math.round(props.flights.reduce((sum, f) => sum + (f.distance ?? 0), 0))
)
const totalDistanceMi = computed(() =>
Math.round(totalDistanceKm.value * 0.621371)
)
const uniqueRoutes = computed(() => {
const keys = new Set(props.flights.map(f =>
[f.departure_airport.id, f.arrival_airport.id].sort().join('-')
@@ -164,10 +156,6 @@ const upcomingDistanceKm = computed(() =>
Math.round(props.upcomingFlights.reduce((sum, f) => sum + (f.distance ?? 0), 0))
)
const upcomingDistanceMi = computed(() =>
Math.round(upcomingDistanceKm.value * 0.621371)
)
const uniqueUpcomingRoutes = computed(() => {
const keys = new Set(props.upcomingFlights.map(f =>
[f.departure_airport.id, f.arrival_airport.id].sort().join('-')
@@ -3,7 +3,6 @@ import { Flight } from "@/Types/types";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue";
defineProps<{
flight: Flight
@@ -0,0 +1,90 @@
<script setup lang="ts" generic="T">
defineProps<{
rows: T[]
rowKey: (row: T) => string | number
hasItems: (row: T) => boolean
labelWidth?: string
}>()
</script>
<template>
<div class="badge-table">
<div
v-for="row in rows"
:key="rowKey(row)"
class="badge-row"
>
<!-- Label column: consumer provides content -->
<div class="badge-label" :style="labelWidth ? `width: ${labelWidth}` : ''">
<slot name="label" :row="row" />
</div>
<!-- Scrollable badge strip -->
<div class="badge-scroll-container">
<div v-if="hasItems(row)" class="badge-strip">
<slot name="items" :row="row" />
</div>
<span v-else class="badge-empty"></span>
</div>
</div>
</div>
</template>
<style scoped>
.badge-table {
width: 100%;
border: 1px solid var(--table-border);
border-bottom: none;
container-type: inline-size;
}
.badge-row {
display: flex;
align-items: center;
border-bottom: 1px solid var(--table-border);
min-height: 2.5em;
}
.badge-label {
flex: 0 0 auto;
width: 15em;
padding: 0.5em;
border-right: 1px solid var(--table-border);
align-self: stretch;
display: flex;
align-items: center;
font-size:0.9rem;
}
@container (max-width: 480px) {
.badge-label {
font-size: 0.75rem;
width: 9em;
}
}
.badge-scroll-container {
flex: 1 1 0;
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
display: flex;
align-items: center;
}
.badge-strip {
display: inline-flex;
align-items: center;
gap: 0.25em;
padding: 0.5em;
white-space: nowrap;
}
.badge-empty {
padding: 0.5em;
color: var(--muted);
}
</style>
@@ -29,8 +29,6 @@ defineProps<{
}
.glass-tooltip {
background: var(--surface);
border: 1px solid var(--table-border);
padding: 10px 14px;
min-width: 180px;
display: flex;
@@ -38,5 +36,6 @@ defineProps<{
gap: 8px;
color: var(--text);
font-size: 0.85rem;
z-index: 20000
}
</style>
@@ -17,7 +17,7 @@ withDefaults(defineProps<{
<style scoped>
.class-badge {
display: inline-block;
display: inline-flex;
font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.1em;
@@ -1,9 +1,11 @@
<script setup lang="ts">
defineProps<{
centered?: boolean
}>()
</script>
<template>
<div class="panel-sub-header"><slot/></div>
<div class="panel-sub-header" :class="centered ? 'centered' : ''"><slot/></div>
</template>
<style scoped>
@@ -12,4 +14,8 @@
color: var(--muted);
margin-bottom: 0.5rem;
}
.centered{
text-align: center;
}
</style>
@@ -9,6 +9,7 @@ const props = defineProps<{
flightCount?: number
achievementCount?: number
isFollowing?: boolean
show: "flights" | "achievements"
}>()
const auth = usePage<SharedProps>().props.auth
@@ -20,6 +21,13 @@ const processing = ref(false)
const snackbar = ref(false)
const snackbarMessage = ref('')
const counts = computed(() => {
return {
flights: props.flightCount ?? 0,
achievements: props.achievementCount ?? 0,
} as Record<"flights" | "achievements", number>
})
const follow = async () => {
processing.value = true
const response = await fetch(route('profile.follow', { user: props.user.name }), {
@@ -61,8 +69,8 @@ const follow = async () => {
</div>
<div class="board-count">
<span class="count-number">{{ flightCount ?? achievementCount }}</span>
<span class="count-label">{{achievementCount ? 'Achievements' : 'Flights'}}</span>
<span class="count-number">{{ counts[show] }}</span>
<span class="count-label">{{show.toUpperCase()}}</span>
</div>
</div>
@@ -114,6 +122,12 @@ const follow = async () => {
margin: 0;
}
@media (max-width: 768px) {
.board-header {
padding: 1em
}
}
.follow-btn {
font-family: 'Share Tech Mono', monospace;
font-size: 0.75rem;
@@ -14,7 +14,7 @@ defineProps<{
<template>
<div class="board-wrapper">
<ProfileHeader :is-following="isFollowing" :user="user" :flightCount="flightCount" />
<ProfileHeader :show="achievementCount && achievementCount > 0 ? 'achievements' : 'flights'" :is-following="isFollowing" :user="user" :flightCount="flightCount" :achievementCount="achievementCount" />
<div v-if="loading" class="loading-state">
<PlaneLoader />
</div>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
value: number
}>()
const formatted = computed(() =>
new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(props.value)
)
</script>
<template>
{{ formatted }}
</template>
<style scoped>
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
import { computed, ComputedRef } from 'vue'
import { Flight } from '@/Types/types'
// ── Types ──────────────────────────────────────────────────────────────────
export interface AircraftFamilyEntry {
family: string
designators: string[]
flights: Flight[]
}
// ── Composable ─────────────────────────────────────────────────────────────
export function useAircraftFamilies(
flights: ComputedRef<Flight[]>,
families: Record<string, string[]>,
) {
// Build a lookup from designator → family name for O(1) matching
const designatorToFamily = Object.entries(families).reduce<Record<string, string>>(
(acc, [family, designators]) => {
for (const d of designators) acc[d] = family
return acc
},
{}
)
const entries = computed<AircraftFamilyEntry[]>(() => {
// Pre-populate all families with empty flight lists
const map = new Map<string, AircraftFamilyEntry>(
Object.entries(families).map(([family, designators]) => [
family,
{ family, designators, flights: [] },
])
)
for (const flight of flights.value) {
const designator = flight.aircraft?.designator
if (!designator) continue
const family = designatorToFamily[designator]
if (!family) continue
map.get(family)!.flights.push(flight)
}
return [...map.values()]
})
const completedCount = computed(() => entries.value.filter(e => e.flights.length > 0).length)
const totalCount = computed(() => entries.value.length)
return { entries, completedCount, totalCount }
}
@@ -0,0 +1,43 @@
import { computed, type Ref } from 'vue'
import type { Flight } from '@/Types/types'
export type CodeType = 'iata' | 'icao'
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
const digits = '0123456789'.split('')
export function getAllLetters(showNumbers: boolean): string[] {
return showNumbers ? [...digits, ...letters] : letters
}
export function useAlphabetAirlines(
flights: Ref<Flight[]>,
codeType: Ref<CodeType>,
showNumbers: Ref<boolean>,
) {
const allLetters = computed(() => getAllLetters(showNumbers.value))
const flightsByLetter = computed(() => {
const map: Record<string, Flight[]> = {}
for (const flight of flights.value) {
const raw = codeType.value === 'iata'
? flight.airline?.IATA_code
: flight.airline?.ICAO_code
const code = raw?.trim().toUpperCase()
if (!code) continue
const key = code[0]
if (!allLetters.value.includes(key)) continue
;(map[key] ??= []).push(flight)
}
return map
})
const visitedLetters = computed(() => new Set(Object.keys(flightsByLetter.value)))
return { flightsByLetter, visitedLetters, allLetters }
}
@@ -0,0 +1,81 @@
import { computed, type ComputedRef, type Ref } from 'vue'
import type { Flight } from '@/Types/types'
export type CodeType = 'iata' | 'icao'
export interface AlphabetFlightData {
/** All 26 uppercase letters AZ */
allLetters: string[]
/** Letters the user has visited (has at least one qualifying airport) */
visitedLetters: ComputedRef<Set<string>>
/** Map of letter → flights that touch an airport starting with that letter */
flightsByLetter: ComputedRef<Record<string, Flight[]>>
/** The code type being used */
codeType: CodeType
}
/**
* Returns which letters of the alphabet have been "visited" based on
* the IATA or ICAO code of the departure or arrival airport on each flight.
*
* A letter is considered visited when at least one flight departs from or
* arrives at an airport whose chosen code starts with that letter.
*
* Airports without a code of the chosen type are ignored.
*/
export function useAlphabetFlights(
flights: Ref<Flight[]> | ComputedRef<Flight[]>,
codeType: CodeType = 'iata',
): AlphabetFlightData {
const allLetters = Array.from({ length: 26 }, (_, i) =>
String.fromCharCode(65 + i), // 'A' … 'Z'
)
/** Pick the right code from an airport, uppercased first character or null */
function getCode(airport: Flight['departure_airport'] | Flight['arrival_airport']): string | null {
const raw = codeType === 'iata' ? airport.iata_code : airport.icao_code
return raw ? raw.trim().toUpperCase() : null
}
const flightsByLetter = computed<Record<string, Flight[]>>(() => {
const map: Record<string, Flight[]> = {}
for (const letter of allLetters) {
map[letter] = []
}
for (const flight of flights.value) {
const codes = new Set<string>()
const depCode = getCode(flight.departure_airport)
if (depCode) codes.add(depCode[0])
const arrCode = getCode(flight.arrival_airport)
if (arrCode) codes.add(arrCode[0])
for (const letter of codes) {
if (letter >= 'A' && letter <= 'Z') {
map[letter]?.push(flight)
}
}
}
return map
})
const visitedLetters = computed<Set<string>>(() => {
const visited = new Set<string>()
for (const [letter, letterFlights] of Object.entries(flightsByLetter.value)) {
if (letterFlights.length > 0) {
visited.add(letter)
}
}
return visited
})
return {
allLetters,
visitedLetters,
flightsByLetter,
codeType,
}
}
@@ -0,0 +1,164 @@
import { computed, ComputedRef } from 'vue'
import { Flight } from '@/Types/types'
export interface Continent {
id: number
name: string
code: string
internal_name: string
}
export interface ContinentPairEntry {
key: string
label: string
flights: Flight[]
}
/** Stable undirected key — always alphabetically sorted so A|B === B|A */
export function undirectedKey(a: string, b: string): string {
return [a, b].sort().join('|')
}
/** Directed key — order preserved */
export function directedKey(dep: string, arr: string): string {
return `${dep}|${arr}`
}
export function labelFor(a: string, b: string): string {
return `${a}${b}`
}
export function continentNameOf(flight: Flight, side: 'departure' | 'arrival'): string | null {
const airport = side === 'departure' ? flight.departure_airport : flight.arrival_airport
return airport.region?.continent?.name ?? null
}
export function isInternational(flight: Flight): boolean {
const depCountry = flight.departure_airport.region?.country_id
const arrCountry = flight.arrival_airport.region?.country_id
if (depCountry == null || arrCountry == null) return true
return depCountry !== arrCountry
}
/** Filter flights to only those that qualify for continent-pair achievements */
export function qualifyingFlights(flights: Flight[]): Flight[] {
return flights.filter(flight => {
const dep = continentNameOf(flight, 'departure')
const arr = continentNameOf(flight, 'arrival')
if (!dep || !arr) return false
if (dep === arr && !isInternational(flight)) return false
return true
})
}
// ── One-way (undirected) pairs ─────────────────────────────────────────────
export function useUndirectedContinentPairs(
flights: ComputedRef<Flight[]>,
continents: ComputedRef<Continent[]>,
) {
const allKeys = computed<string[]>(() => {
const names = continents.value.map(c => c.name).sort()
const keys: string[] = []
for (let i = 0; i < names.length; i++) {
for (let j = i; j < names.length; j++) {
keys.push(undirectedKey(names[i], names[j]))
}
}
return keys.sort((a, b) => {
const [a1, a2] = a.split('|')
const [b1, b2] = b.split('|')
return labelFor(a1, a2).localeCompare(labelFor(b1, b2))
})
})
const flightsByKey = computed<Map<string, Flight[]>>(() => {
const map = new Map(allKeys.value.map(k => [k, [] as Flight[]]))
for (const flight of qualifyingFlights(flights.value)) {
const dep = continentNameOf(flight, 'departure')!
const arr = continentNameOf(flight, 'arrival')!
const key = undirectedKey(dep, arr)
map.get(key)?.push(flight)
}
return map
})
const entries = computed<ContinentPairEntry[]>(() =>
allKeys.value.map(key => {
const [a, b] = key.split('|')
return { key, label: labelFor(a, b), flights: flightsByKey.value.get(key) ?? [] }
})
)
const completedCount = computed(() => entries.value.filter(e => e.flights.length > 0).length)
const totalCount = computed(() => entries.value.length)
return { entries, completedCount, totalCount }
}
// ── Both-ways (directed) pairs ─────────────────────────────────────────────
export function useDirectedContinentPairs(
flights: ComputedRef<Flight[]>,
continents: ComputedRef<Continent[]>,
) {
/** All directed keys grouped by the departure continent name */
const keysByDeparture = computed<Map<string, string[]>>(() => {
const names = continents.value.map(c => c.name).sort()
const map = new Map<string, string[]>()
for (const dep of names) {
map.set(dep, names.map(arr => directedKey(dep, arr)).sort((a, b) => {
const [, a2] = a.split('|')
const [, b2] = b.split('|')
return a2.localeCompare(b2)
}))
}
return map
})
const flightsByKey = computed<Map<string, Flight[]>>(() => {
const map = new Map<string, Flight[]>()
for (const keys of keysByDeparture.value.values()) {
for (const key of keys) map.set(key, [])
}
for (const flight of qualifyingFlights(flights.value)) {
const dep = continentNameOf(flight, 'departure')!
const arr = continentNameOf(flight, 'arrival')!
const key = directedKey(dep, arr)
map.get(key)?.push(flight)
}
return map
})
/** Entries grouped by departure continent, each sorted by arrival continent name */
const entriesByDeparture = computed<Map<string, ContinentPairEntry[]>>(() => {
const map = new Map<string, ContinentPairEntry[]>()
for (const [dep, keys] of keysByDeparture.value) {
map.set(dep, keys.map(key => {
const [a, b] = key.split('|')
return { key, label: `${a}${b}`, flights: flightsByKey.value.get(key) ?? [] }
}))
}
return map
})
const departureNames = computed(() => [...keysByDeparture.value.keys()])
const completedCount = computed(() => {
let count = 0
for (const entries of entriesByDeparture.value.values()) {
count += entries.filter(e => e.flights.length > 0).length
}
return count
})
const totalCount = computed(() => {
let count = 0
for (const entries of entriesByDeparture.value.values()) {
count += entries.length
}
return count
})
return { entriesByDeparture, departureNames, completedCount, totalCount }
}
@@ -33,7 +33,6 @@ function getDateParts(f: Flight): { year: number; month: number; day: number } {
// ── Per year / month / day ────────────────────────────────────────────────────
export function getFlightsPerYear(flights: Flight[], upcomingFlights: Flight[]) {
console.time('getFlightsPerYear')
const allFlights = [...flights, ...upcomingFlights]
const allYears = new Set(allFlights.map(f => getDateParts(f).year))
@@ -57,12 +56,10 @@ export function getFlightsPerYear(flights: Flight[], upcomingFlights: Flight[])
{ name: 'Upcoming', data: countByYear(upcomingFlights) },
],
}
console.timeEnd('getFlightsPerYear')
return result
}
export function getFlightsPerMonth(flights: Flight[], upcomingFlights: Flight[]) {
console.time('getFlightsPerMonth')
const countByMonth = (list: Flight[]) =>
MONTHS.map((_, i) => list.filter(f => getDateParts(f).month === i).length)
@@ -73,12 +70,10 @@ export function getFlightsPerMonth(flights: Flight[], upcomingFlights: Flight[])
{ name: 'Upcoming', data: countByMonth(upcomingFlights) },
],
}
console.timeEnd('getFlightsPerMonth')
return result
}
export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
console.time('getFlightsPerDay')
const countByDay = (list: Flight[]) =>
DAYS.map((_, i) => list.filter(f => getDateParts(f).day === i).length)
@@ -89,7 +84,6 @@ export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
{ name: 'Upcoming', data: countByDay(upcomingFlights) },
],
}
console.timeEnd('getFlightsPerDay')
return result
}
@@ -109,23 +103,17 @@ function groupByName(flights: Flight[], accessor: (f: Flight) => string) {
}
export function getFlightReasons(flights: Flight[]) {
console.time('getFlightReasons')
const result = groupByName(flights, f => f.flight_reason?.name ?? 'Unknown')
console.timeEnd('getFlightReasons')
return result
}
export function getFlightClasses(flights: Flight[]) {
console.time('getFlightClasses')
const result = groupByName(flights, f => f.flight_class?.name ?? 'Unknown')
console.timeEnd('getFlightClasses')
return result
}
export function getSeatTypes(flights: Flight[]) {
console.time('getSeatTypes')
const result = groupByName(flights, f => f.seat_type?.name ?? 'Unknown')
console.timeEnd('getSeatTypes')
return result
}
@@ -152,7 +140,6 @@ function countCountries(flights: Flight[]) {
}
export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
console.time('getCountries')
const past = countCountries(flights)
const upcoming = countCountries(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
@@ -173,14 +160,12 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
console.timeEnd('getCountries')
return result
}
// ── Continents ────────────────────────────────────────────────────────────────
export function getContinents(flights: Flight[]) {
console.time('getContinents')
const counts = new Map<string, number>()
flights.forEach(f => {
const continents = new Set<string>()
@@ -195,7 +180,6 @@ export function getContinents(flights: Flight[]) {
labels: sorted.map(([name]) => name),
series: sorted.map(([, count]) => count),
}
console.timeEnd('getContinents')
return result
}
@@ -214,7 +198,6 @@ function countAirlines(flights: Flight[]) {
}
export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
console.time('getTopAirlines')
const past = countAirlines(flights)
const upcoming = countAirlines(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
@@ -236,7 +219,6 @@ export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
console.timeEnd('getTopAirlines')
return result
}
@@ -256,7 +238,6 @@ function airportLabel(airport: Airport | null | undefined): string | null {
}
export function getTopAirports(flights: Flight[], upcomingFlights: Flight[]) {
console.time('getTopAirports')
const map = new Map<string, AirportItem>()
const empty = (): AirportItem => ({ departures: 0, arrivals: 0, upcoming: 0, label: '', fullName: '' })
@@ -313,7 +294,6 @@ export function getTopAirports(flights: Flight[], upcomingFlights: Flight[]) {
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
console.timeEnd('getTopAirports')
return result
}
@@ -421,7 +401,6 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
// ── Flight types ──────────────────────────────────────────────────────────────
export function getFlightTypes(flights: Flight[]) {
console.time('getFlightTypes')
const counts = { International: 0, Domestic: 0 }
flights.forEach(f => {
const dep = f.departure_airport.region?.country?.id
@@ -435,7 +414,6 @@ export function getFlightTypes(flights: Flight[]) {
labels: sorted.map(([name]) => name),
series: sorted.map(([, count]) => count),
}
console.timeEnd('getFlightTypes')
return result
}
@@ -447,22 +425,16 @@ export function useFlightStats(flights: Ref<Flight[]>) {
const now = new Date()
watch(flights, (list) => {
console.time('dateCache warm')
list.forEach(f => getDateParts(f))
console.timeEnd('dateCache warm')
}, { immediate: true })
const pastFlights = computed(() => {
console.time('pastFlights')
const result = flights.value.filter(f => new Date(f.departure_date) <= now)
console.timeEnd('pastFlights')
return result
})
const upcomingFlights = computed(() => {
console.time('upcomingFlights')
const result = flights.value.filter(f => new Date(f.departure_date) > now)
console.timeEnd('upcomingFlights')
return result
})
+4 -2
View File
@@ -3,13 +3,15 @@ import {onMounted, ref} from "vue";
import {Flight} from "@/Types/types";
import axios from "axios";
export function useFlights(url: string) {
export function useFlights(url: string, departedOnly: boolean = false) {
const flights = ref<Flight[]>([])
const flightsLoading = ref(true)
onMounted(async () => {
try {
const response = await axios.get(url)
const response = await axios.get(url, {
params: departedOnly ? { departed_only: true } : {}
})
flights.value = response.data
} finally {
flightsLoading.value = false
+9 -1
View File
@@ -21,7 +21,7 @@ export function useRegionFlights(
})
const flightsByRegion = computed(() => {
return filteredFlights.value.reduce((grouped, flight) => {
const grouped = filteredFlights.value.reduce((grouped, flight) => {
const dep = flight.departure_airport?.region
const arr = flight.arrival_airport?.region
@@ -37,6 +37,14 @@ export function useRegionFlights(
return grouped
}, {} as Record<string, Flight[]>)
return Object.fromEntries(
Object.entries(grouped).sort(([codeA], [codeB]) => {
const nameA = regionNames.value[codeA] ?? codeA
const nameB = regionNames.value[codeB] ?? codeB
return nameA.localeCompare(nameB)
})
)
})
const regionNames = computed(() => {
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, Flight, User } from '@/Types/types'
import { useAircraftFamilies } from '@/Composables/useAircraftFamilies'
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'
const props = defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
flights: Flight[]
families: Record<string, string[]>
}>()
const flights = computed(() => props.flights)
const { entries } = useAircraftFamilies(flights, props.families)
</script>
<template>
<Panel>
<div class="table-toolbar">
<PanelHeader>Families</PanelHeader>
</div>
<BadgeTable
:rows="entries"
:rowKey="entry => entry.family"
:hasItems="entry => entry.flights.length > 0"
labelWidth="8em"
>
<template #label="{ row: entry }">
<div class="family-label">
<span class="family-name">Airbus {{ entry.family }}</span>
</div>
</template>
<template #items="{ row: entry }">
<FlightBadge
v-for="flight in entry.flights"
:key="flight.id"
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
:flight="flight"
/>
</template>
</BadgeTable>
</Panel>
<slot name="extra" />
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly on at least one aircraft from every
Airbus A3xx family the A300, A310, A318, A319, A320, A321, A330, A340, A350,
and A380.
</p>
<p>
Any variant within a family counts. For example, an Airbus A320neo (A20N) and
a classic A320 both satisfy the A320 family requirement.
</p>
</Panel>
<Panel>
<PanelHeader centered>Difficulty</PanelHeader>
<p>
If you are starting this challenge today, then this challenge is near-impossible to complete and will soon be impossible. At the time of writing, the following aircraft are very hard to fly on:
</p>
<ul>
<li><b>Airbus A300</b> - Only in service in Iran, mostly domestically. Very hard to arrange.</li>
<li><b>Airbus A310</b> - In commercial service in Iran and Afghanistan. Afghanistan is far less sanctioned, so easier to book a flight. Dubai to Kabul is a reliabe route to see the A310. An A310 also acts as a vomit comet in Europe</li>
<li><b>Airbus A318</b> - Very few left operating for Air France. Will likely be retired before you have read this.</li>
<li><b>Airbus A340</b> - Rapidly being retired from service in the western world with Lufthansa being the last hold out (at time of writing). Iran and Venezuela are likely to hold on to them for sometime. South African always seems to bring them back into service too. A few more scattered around in charter or smaller national airlines</li>
</ul>
</Panel>
</template>
<style scoped>
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.family-label {
width: 100%;
display: flex;
align-items: center;
padding: 0 0.25rem;
}
.family-name {
font-size: 0.9rem;
font-weight: 500;
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, Flight, User } from '@/Types/types'
import { useAircraftFamilies } from '@/Composables/useAircraftFamilies'
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 FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
const props = defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
flights: Flight[]
families: Record<string, string[]>
}>()
const flights = computed(() => props.flights)
const { entries, completedCount, totalCount } = useAircraftFamilies(flights, props.families)
</script>
<template>
<Panel>
<div class="table-toolbar">
<PanelHeader>Families</PanelHeader>
</div>
<BadgeTable
:rows="entries"
:rowKey="entry => entry.family"
:hasItems="entry => entry.flights.length > 0"
labelWidth="8em"
>
<template #label="{ row: entry }">
<div class="family-label">
<span class="family-name">Boeing {{ entry.family }}</span>
</div>
</template>
<template #items="{ row: entry }">
<FlightBadge
v-for="flight in entry.flights"
:key="flight.id"
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
:flight="flight"
/>
</template>
</BadgeTable>
</Panel>
<slot name="extra" />
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly on at least one aircraft from every
Boeing 7x7 family the 707, 717, 727, 737, 747, 757, 767, 777, and 787.
</p>
<p>
Any variant within a family counts. For example, a Boeing 737-800 (B738) and
a Boeing 737 MAX 8 (B38M) both satisfy the 737 family requirement.
</p>
</Panel>
<Panel>
<PanelHeader centered>Difficulty</PanelHeader>
<p>
If you are starting this challenge today, then this challenge is impossible to complete and will not count towards your achievement total. If you have historically completed it, it will.
</p>
<p>
Difficult types to fly on
</p>
<ul>
<li><b>Boeing 707</b> - Completely impossible to fly today. The US Military still operates some derivatives, but they do not count towards this challenge</li>
<li><b>Boeing 727</b> - No commercial service. One operates as a vomit comet in the USA. Very expensive, but technically possible to fly on</li>
<li><b>Boeing 717</b> - Still in service, mostly in the US, but being withdrawn rapidly.</li>
<li><b>Boeing 747</b> - Being pulled from service rapidly. Almost no 747-400s left in service (just Lufthansa). 747-8s are still active with Korean Air, Air China and Lufthansa but the US Air Force has acquired a large number from these airlines and they are dwindling</li>
<li><b>Boeing 757</b> - Still fairly common in the USA but quickly going extinct elsewhere.</li>
</ul>
</Panel>
</template>
<style scoped>
.family-name {
font-size: 0.9rem;
font-weight: 500;
}
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
alliance: Alliance
airlines: Airline[]
flights: Flight[]
}>()
</script>
<template>
<AllianceChallenge
:achievement="achievement"
:user="user"
:isFollowing="isFollowing"
:alliance="alliance"
:airlines="airlines"
:flights="flights"
>
<p>
The <b>Oneworld</b> Alliance is currently the smallest of the big 3 major alliances, but with members all around the world it's no mean feat to get on all of them.
</p>
</AllianceChallenge>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
alliance: Alliance
airlines: Airline[]
flights: Flight[]
}>()
</script>
<template>
<AllianceChallenge
:achievement="achievement"
:user="user"
:isFollowing="isFollowing"
:alliance="alliance"
:airlines="airlines"
:flights="flights"
>
<p>
The SkyTeam Alliance is the youngest of the 3 major alliances, but does not have the smallest roster!
</p>
</AllianceChallenge>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
alliance: Alliance
airlines: Airline[]
flights: Flight[]
}>()
</script>
<template>
<AllianceChallenge
:achievement="achievement"
:user="user"
:isFollowing="isFollowing"
:alliance="alliance"
:airlines="airlines"
:flights="flights"
>
<p>
Fly with every member airline of <b>Star Alliance</b>, the world's largest airline
alliance by number of member carriers.
</p>
</AllianceChallenge>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
alliance: Alliance
airlines: Airline[]
flights: Flight[]
}>()
</script>
<template>
<AllianceChallenge
:achievement="achievement"
:user="user"
:isFollowing="isFollowing"
:alliance="alliance"
:airlines="airlines"
:flights="flights"
>
<p>
<b>Vanilla Alliance</b> is a small, regional alliance in the Vanilla Islands. It is a paper alliance that is effectively defunct, but it's still a fun one to try for!
</p>
</AllianceChallenge>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, Flight, User } from '@/Types/types'
import { Continent, useDirectedContinentPairs } from '@/Composables/useContinentPairs'
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 FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
const props = defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
flights: Flight[]
continents: Continent[]
}>()
const flights = computed(() => props.flights)
const continents = computed(() => props.continents.filter(c => c.internal_name !== 'antarctica'))
const { entriesByDeparture, departureNames, completedCount, totalCount } =
useDirectedContinentPairs(flights, continents)
</script>
<template>
<!-- One panel per departure continent -->
<Panel v-for="dep in departureNames" :key="dep">
<div class="table-toolbar">
<PanelHeader>Departing {{ dep }}</PanelHeader>
</div>
<BadgeTable
:rows="entriesByDeparture.get(dep) ?? []"
:rowKey="entry => entry.key"
:hasItems="entry => entry.flights.length > 0"
labelWidth="18em"
>
<template #label="{ row: entry }">
<div class="pair-label">
<span class="pair-name">{{ entry.label }}</span>
</div>
</template>
<template #items="{ row: entry }">
<FlightBadge
v-for="flight in entry.flights"
:key="flight.id"
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
:flight="flight"
/>
</template>
</BadgeTable>
</Panel>
<slot name="extra" />
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly between every possible pair of continents
in <strong>both directions</strong> a flight from Europe to Asia and a separate
flight from Asia to Europe are each required.
</p>
<p>
For <strong>same-continent routes</strong> (e.g. Africa Africa), only
<strong>international flights</strong> count domestic flights within the same
country are excluded.
</p>
</Panel>
</template>
<style scoped>
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
</style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, Flight, User } from '@/Types/types'
import { Continent, useUndirectedContinentPairs } from '@/Composables/useContinentPairs'
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'
const props = defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
flights: Flight[]
continents: Continent[]
}>()
const flights = computed(() => props.flights)
const continents = computed(() => props.continents.filter(c => c.internal_name !== 'antarctica'))
const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flights, continents)
</script>
<template>
<!-- Continent pairs table -->
<Panel>
<div class="table-toolbar">
<PanelHeader>Continent Pairs</PanelHeader>
</div>
<BadgeTable
:rows="entries"
:rowKey="entry => entry.key"
:hasItems="entry => entry.flights.length > 0"
labelWidth="18em"
>
<template #label="{ row: entry }">
<div class="pair-label">
<span class="pair-name">{{ entry.label }}</span>
</div>
</template>
<template #items="{ row: entry }">
<FlightBadge
v-for="flight in entry.flights"
:key="flight.id"
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
:flight="flight"
/>
</template>
</BadgeTable>
</Panel>
<slot name="extra" />
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly at least one flight between every possible
pair of continents including flights within the same continent.
</p>
<p>
For <strong>same-continent pairs</strong> (e.g. Africa Africa), only
<strong>international flights</strong> count domestic flights within the same
country are excluded.
</p>
</Panel>
</template>
<style scoped>
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.pair-label {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.25rem;
}
.pair-name {
font-size: 0.9rem;
}
</style>
@@ -0,0 +1,278 @@
<script setup lang="ts">
import { Achievement, 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 AirlineAlphabetTable from '@/Components/FlightsGoneBy/AirlineAlphabetTable.vue'
import { computed, ref } from 'vue'
import { useAlphabetAirlines, type CodeType } from '@/Composables/useAlphabetAirlines'
const props = defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
flights: Flight[]
}>()
const codeType = ref<CodeType>('iata')
const showNumbers = ref(false)
const flightsRef = computed(() => props.flights)
const { flightsByLetter, visitedLetters, allLetters } = useAlphabetAirlines(
flightsRef,
codeType,
showNumbers,
)
// Only count letters (AZ) towards progress, never digits
const visitedLetterCount = computed(() =>
[...visitedLetters.value].filter(k => isNaN(Number(k))).length
)
const availableYears = computed(() => {
const years = new Set(props.flights.map(f => new Date(f.departure_date).getFullYear()))
return [...years].sort((a, b) => b - a)
})
const yearItems = computed(() => [
{ title: 'None', value: null },
...availableYears.value.map(y => ({ title: String(y), value: y })),
])
const currentYear = new Date().getFullYear()
const selectedYear = ref<number | null>(
availableYears.value.includes(currentYear) ? currentYear : (availableYears.value[0] ?? null)
)
// Copy BBCode
const airlineTable = ref<InstanceType<typeof AirlineAlphabetTable> | null>(null)
const copied = ref(false)
async function copyBBCode() {
const text = airlineTable.value?.toBBCode()
if (text == null) return
try {
await navigator.clipboard.writeText(text)
} catch {
const el = document.createElement('textarea')
el.value = text
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
document.body.appendChild(el)
el.focus()
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}
</script>
<template>
<!-- Challenge description -->
<Panel>
<PanelHeader centered>The Airline Alphabet Challenge</PanelHeader>
<PanelSubHeader centered>
<p>
A twist on the classic Alphabet Challenge instead of airports, you need to fly with
airlines whose <b>IATA codes</b> begin with each letter of the alphabet.
</p>
<p>
You can copy your results as BBCode using the copy icon above the table, and select
a year to highlight airlines that are new for that given year.
</p>
</PanelSubHeader>
</Panel>
<!-- Progress grid -->
<Panel style="display: flex; flex-direction: column; align-items: center; gap: 1rem;">
<PanelHeader centered>Progress</PanelHeader>
<div class="toggle-row">
<div>
<button :class="['code-toggle', codeType === 'iata' ? 'active' : '']" @click="codeType = 'iata'">IATA</button>
<button :class="['code-toggle', codeType === 'icao' ? 'active' : '']" @click="codeType = 'icao'">ICAO</button>
</div>
<button :class="['code-toggle', showNumbers ? 'active' : '']" @click="showNumbers = !showNumbers">
Show 09
</button>
</div>
<p class="progress-summary">{{ visitedLetterCount }} / {{allLetters.length}} letters visited</p>
<div class="alphabet-grid">
<div
v-for="letter in allLetters"
:key="letter"
:class="['letter-tile', visitedLetters.has(letter) ? 'visited' : 'unvisited']"
:title="visitedLetters.has(letter) ? 'Visited' : 'Not yet visited'"
>
{{ letter }}
</div>
</div>
</Panel>
<!-- Airlines by letter -->
<Panel>
<div class="table-toolbar">
<PanelHeader>Airlines By Letter</PanelHeader>
<div class="toolbar-right">
<v-select
v-model="selectedYear"
:items="yearItems"
item-title="title"
item-value="value"
label="New For"
density="compact"
variant="outlined"
hide-details
class="year-select"
/>
<v-btn
:icon="copied ? 'mdi-check' : 'mdi-content-copy'"
:color="copied ? 'success' : undefined"
density="compact"
variant="text"
title="Copy as BBCode"
@click="copyBBCode"
/>
</div>
</div>
<AirlineAlphabetTable
ref="airlineTable"
:letters="allLetters"
:flightsByLetter="flightsByLetter"
:codeType="codeType"
:selectedYear="selectedYear"
/>
</Panel>
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly with airlines whose IATA codes begin with each
of the 26 letters of the alphabet A through Z. Airlines without an IATA code do not count.
</p>
<p>
A single flight contributes one letter the operating airline's IATA code. So a flight
on <strong>QF</strong> (Qantas) counts toward <strong>Q</strong>.
</p>
<p>
ICAO codes do not count towards this challenge, but you can toggle to ICAO view to see
where you're at for fun. Similarly, numeric IATA codes (such as <strong>3U</strong>) exist
but do not count towards the challenge enable "Show 09" to see them.
</p>
<p>
It would unlikely to be possible to complete the challenge with both numbers and letters required.
</p>
</Panel>
<!-- Difficulty -->
<Panel>
<PanelHeader centered>Difficulty</PanelHeader>
<p>
Like the airport version, this challenge is <b>very</b> difficult, but it is slightly easier and most letters have a fair few options.
Some difficulty.
</p>
<ul>
<li><b>X</b> Very few airlines; worth researching regional carriers in your area.</li>
</ul>
<p>
Most other letters have a reasonable selection of options across different continents,
though some will require deliberate routing to achieve.
</p>
</Panel>
</template>
<style scoped>
.progress-summary {
font-size: 1.2rem;
font-weight: 600;
color: var(--text);
}
.toggle-row {
display: flex;
align-items: center;
gap: 1rem;
}
.code-toggle {
padding: 0.15rem 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: inherit;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.code-toggle.active {
background: var(--accent-glow);
color: var(--accent);
border-color: var(--accent);
}
.alphabet-grid {
display: grid;
grid-template-columns: repeat(13, 1fr);
gap: 0.4rem;
width: 100%;
max-width: 520px;
}
@media (max-width: 480px) {
.alphabet-grid {
grid-template-columns: repeat(7, 1fr);
}
}
.letter-tile {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1rem;
border-radius: 6px;
user-select: none;
}
.letter-tile.visited {
background: var(--accent-glow);
color: var(--accent);
border: 1px solid var(--accent-soft);
}
.letter-tile.unvisited {
background: var(--surface-alt);
color: var(--muted);
border: 1px solid var(--border);
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.year-select {
max-width: 140px;
}
</style>
@@ -0,0 +1,280 @@
<script setup lang="ts">
import { Achievement, 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 AlphabetTable from '@/Components/FlightsGoneBy/AlphabetTable.vue'
import { computed, ref } from 'vue'
import { useAlphabetFlights, type CodeType } from '@/Composables/useAlphabetFlights'
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
const props = defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
flights: Flight[]
}>()
const codeType = ref<CodeType>('iata')
const flightsRef = computed(() => props.flights)
const visited = computed(() => useAlphabetFlights(flightsRef, codeType.value).visitedLetters.value)
const byLetter = computed(() => useAlphabetFlights(flightsRef, codeType.value).flightsByLetter.value)
const { allLetters } = useAlphabetFlights(flightsRef, codeType.value)
const visitedCount = computed(() => visited.value.size)
const availableYears = computed(() => {
const years = new Set(props.flights.map(f => new Date(f.departure_date).getFullYear()))
return [...years].sort((a, b) => b - a)
})
// Year select items: "None" option + all flight years
const yearItems = computed(() => [
{ title: 'None', value: null },
...availableYears.value.map(y => ({ title: String(y), value: y })),
])
const currentYear = new Date().getFullYear()
const selectedYear = ref<number | null>(
availableYears.value.includes(currentYear) ? currentYear : (availableYears.value[0] ?? null)
)
// Copy to clipboard
const alphabetTable = ref<InstanceType<typeof AlphabetTable> | null>(null)
const copied = ref(false)
async function copyBBCode() {
const text = alphabetTable.value?.toBBCode()
if (text == null) return
try {
await navigator.clipboard.writeText(text)
} catch {
// Fallback for non-HTTPS or browsers that block clipboard API
const el = document.createElement('textarea')
el.value = text
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
document.body.appendChild(el)
el.focus()
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}
</script>
<template>
<!-- Challenge description -->
<Panel>
<PanelHeader centered>The Alphabet Challenge</PanelHeader>
<PanelSubHeader centered>
<p>
Originating from aviation forums, as <b>Land at the Alphabet</b>, this challenge is a tad more lenient
as it allows for both landing and taking off to count towards a letter.
</p>
<p>
In honor of this challenge's forum roots, you can copy your results of this challenge as BBCode by pressing the copy icon above the table, and select
a year to highlight airports that are new for that given year.
</p>
</PanelSubHeader>
</Panel>
<!-- Progress grid -->
<Panel style="display: flex; flex-direction: column; align-items: center; gap: 1rem;">
<PanelHeader centered>Progress</PanelHeader>
<div>
<button :class="['code-toggle', codeType === 'iata' ? 'active' : '']" @click="codeType = 'iata'">IATA</button>
<button :class="['code-toggle', codeType === 'icao' ? 'active' : '']" @click="codeType = 'icao'">ICAO</button>
</div>
<p class="progress-summary">{{ visitedCount }} / {{ allLetters.length }} letters visited</p>
<div class="alphabet-grid">
<div
v-for="letter in allLetters"
:key="letter"
:class="['letter-tile', visited.has(letter) ? 'visited' : 'unvisited']"
:title="visited.has(letter) ? 'Visited' : 'Not yet visited'"
>
{{ letter }}
</div>
</div>
</Panel>
<!-- Airports by letter -->
<Panel>
<div class="table-toolbar">
<PanelHeader>Airports By Letter</PanelHeader>
<div class="toolbar-right">
<v-select
v-model="selectedYear"
:items="yearItems"
item-title="title"
item-value="value"
label="New For"
density="compact"
variant="outlined"
hide-details
class="year-select"
/>
<v-btn
:icon="copied ? 'mdi-check' : 'mdi-content-copy'"
:color="copied ? 'success' : undefined"
density="compact"
variant="text"
title="Copy as BBCode"
@click="copyBBCode"
/>
</div>
</div>
<AlphabetTable
ref="alphabetTable"
:letters="allLetters"
:flightsByLetter="byLetter"
:codeType="codeType"
:selectedYear="selectedYear"
/>
</Panel>
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must take off from or land at airports whose
IATA codes begin with each of the 26 letters of the alphabet —
A through Z. Airports without an IATA code do not count.
</p>
<p>
Both the departure and arrival airport on a single flight can count toward different
letters simultaneously, so a flight from <strong>ABX</strong> to <strong>BNE</strong>
would count toward both A and B.
</p>
<p>
ICAO codes do not count towards this challenge (and it is not possible to visit an ICAO code for every letter), but you can
toggle to ICAO view to see where you're at for fun.
</p>
</Panel>
<!-- Difficulty -->
<Panel>
<PanelHeader centered>Difficulty</PanelHeader>
<p>
This is a <b>very</b> difficult challenge no matter how well travelled you are. However it one of the most fun to try and accomplish as it will get you travelling
to places you might never have otherwise considered.
</p>
<p>
Most letters are possible to visit somewhat organically if you fly enough, but some will be difficult. <b>Q</b> is probably the most difficult letter to obtain -
there's no rules against having a code starting with Q, but due to many aviation terms starting with Q (such as QNH), very few airports use it.
</p>
<p>
At time of writing, there are just 2 major airports that have daily scheduled service by major airlines, and 4 airports with any commercial service at all.
Others - such as seaplane ports - might have GA or charter service.
</p>
<ul>
<li><b>QRO</b> in Mexico - a fantastic place to visit and with flights from around Mexico and the US.</li>
<li><b>QSZ</b> in China is also a very interesting place to visit and is served well from Urumqi and Xi'an.</li>
<li><b>QBC</b> is a small airport in Canada and has daily runs on commuter aircraft from Vancouver.</li>
<li><b>QSR</b> is probably most promising recently - it did not have commercial service for 10 years and is having good growth now with European low cost carriers.</li>
</ul>
<p>
Other potential candidates do not seem so promising
</p>
<ul>
<li><b>QAH</b> was in India as a secondary Delhi airport, but the code was changed to <b>HDO</b> </li>
<li><b>QAJ</b> was proposed for Ajman Airport which might have acted as an alternate to Dubai and Sharjah, but construction has not moved forward.</li>
</ul>
<p>
Every other letter usually has a decent selection of options across most continents.
</p>
</Panel>
</template>
<style scoped>
.progress-summary {
font-size: 1.2rem;
font-weight: 600;
color: var(--text);
}
.code-toggle {
padding: 0.15rem 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: inherit;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.code-toggle.active {
background: var(--accent-glow);
color: var(--accent);
border-color: var(--accent);
}
.alphabet-grid {
display: grid;
grid-template-columns: repeat(13, 1fr);
gap: 0.4rem;
width: 100%;
max-width: 520px;
}
@media (max-width: 480px) {
.alphabet-grid {
grid-template-columns: repeat(7, 1fr);
}
}
.letter-tile {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1rem;
border-radius: 6px;
user-select: none;
}
.letter-tile.visited {
background: var(--accent-glow);
color: var(--accent);
border: 1px solid var(--accent-soft);
}
.letter-tile.unvisited {
background: var(--surface-alt);
color: var(--muted);
border: 1px solid var(--border);
}
/* Table toolbar */
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.year-select {
max-width: 140px;
}
</style>
@@ -36,9 +36,9 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
<template>
<Panel>
<PanelHeader centered>The Challenge</PanelHeader>
<p>
<PanelSubHeader centered>
Either take off or land from an airport in each Australian State and Internal Territory.
</p>
</PanelSubHeader>
</Panel>
<Panel style="display:flex; flex-direction: column; align-items: center; justify-content: center;">
<PanelHeader centered>Progress</PanelHeader>
@@ -46,7 +46,7 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
<br/>
<PanelHeader center>Flights By State</PanelHeader>
<PanelSubHeader>5 Most Recent Flights By State</PanelSubHeader>
<FlightRegionTable
:regionCodes="stateLocalCodes"
:regionNames="regionNames"
@@ -56,7 +56,7 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
The 8 states and territories you need to visit to complete this challenge are Queensland, Victoria, New South Wales, South Australia, Tasmania, Western Australia, the Northern Territory
The 8 states and territories you need to visit to complete this challenge are Queensland, Victoria, New South Wales, South Australia, Tasmania, Western Australia, the Northern Territory
and the Australian Capital Territory.
</p>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import {Achievement, Flight, Region, User} from "@/Types/types";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import {computed} from "vue";
import PanelSubHeader from "@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue";
import FlightRegionTable from "@/Components/FlightsGoneBy/FlightRegionTable.vue";
import {useRegionFlights} from "@/Composables/useRegionFlights";
import RegionLegend from "@/Components/FlightsGoneBy/Panels/RegionLegend.vue";
import Brazil from "@/Components/Maps/Brazil.vue";
defineOptions({ layout: MainLayout })
const props = defineProps<{
achievement: Achievement
user: User
isFollowing: boolean
flights: Flight[]
regions: Region[]
}>()
const countryCode = 'BR'
const stateLocalCodes = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
computed(() => props.flights),
stateLocalCodes,
countryCode,
computed(() => props.regions)
)
</script>
<template>
<Panel>
<PanelHeader centered>The Challenge</PanelHeader>
<p>
Either take off or land from an airport in each Brazilian State plus the Distrito Federal (Brasilia).
</p>
</Panel>
<Panel style="display:flex; flex-direction: column; align-items: center; justify-content: center;">
<PanelHeader centered>Progress</PanelHeader>
<Brazil :visitedStates="visitedRegions" :flights="flightsByRegion" style="width: 100%;"/>
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
<br/>
<PanelHeader center>Flights By State</PanelHeader>
<FlightRegionTable
:regionCodes="stateLocalCodes"
:regionNames="regionNames"
:flightsByRegion="flightsByRegion"
/>
</Panel>
<Panel>
<PanelHeader centered>Difficulty</PanelHeader>
<p>
This is a very difficult challenge - Brazil is a reasonably mature aviation market by South American standards, but flights can still be expensive and the major
hubs tend to be in the south so you can expect a lot of doubling back. Brasilia might not be a bad base as it's quite central and well connected, but if you're trying
to achieve this trophy organically, it's unlikely to just happen!
</p>
</Panel>
</template>
<style scoped>
</style>
@@ -49,8 +49,7 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
<Canada :visitedStates="visitedRegions" :flights="flightsByRegion" style="width: 100%;"/>
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
<br/>
<PanelHeader center>Flights By State</PanelHeader>
<PanelSubHeader>5 Most Recent Flights By State</PanelSubHeader>
<PanelHeader centered>Flights By Province</PanelHeader>
<FlightRegionTable
:regionCodes="stateLocalCodes"
:regionNames="regionNames"
@@ -60,7 +59,7 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
<Panel>
<PanelHeader centered>Difficulty</PanelHeader>
<p>
This challenge is not extremely difficult bur Canada's geography does complicate it and it could get expensive!
This challenge is not extremely difficult but Canada's geography does complicate it and it could get expensive!
</p>
<p>
Canada's population is all in the South, so visiting the Northwest Territories, Yukon and Nanavut will likely require doubling back south to get connecting flights.
@@ -47,7 +47,6 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
<br/>
<PanelHeader center>Flights By Region</PanelHeader>
<PanelSubHeader>5 Most Recent Flights Per Region</PanelSubHeader>
<FlightRegionTable
:regionCodes="stateLocalCodes"
:regionNames="regionNames"
@@ -53,7 +53,6 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
<RegionLegend :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes" :regionNames="regionNames"/>
<br/>
<PanelHeader center>Flights By State</PanelHeader>
<PanelSubHeader>5 Most Recent Flights By State</PanelSubHeader>
<FlightRegionTable
:regionCodes="stateLocalCodes"
:regionNames="regionNames"
+28 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {Achievement, Region, User, UserAchievement} from "@/Types/types";
import {Achievement, Airline, Continent, Region, User, UserAchievement} from "@/Types/types";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import {computed, defineAsyncComponent} from 'vue';
@@ -11,6 +11,7 @@ import MainLayout from "@/Layouts/MainLayout.vue";
import {useFlights} from "@/Composables/useFlights";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
defineOptions({ layout: MainLayout })
@@ -18,13 +19,18 @@ const props = defineProps<{
achievement: Achievement
userAchievement: UserAchievement
user: User
loggedInUser: User | null
isFollowing: boolean
flight_api_url: string
regions: Region[]
alliance: string | null
airlines: Airline[]
continents: Continent[]
aircraft_families: Record<string, string[]>
}>()
const { flights, flightsLoading } = useFlights(props.flight_api_url)
const { flights, flightsLoading } = useFlights(props.flight_api_url, true)
const AchievementDetail = defineAsyncComponent(
() => import(`./Achievements/${props.achievement.internal_name}.vue`)
@@ -63,14 +69,14 @@ const difficultyVariant = computed(() => {
<ProfileLayout :user="user" :isFollowing="isFollowing" :loading="flightsLoading">
<Head :title="`${achievement.name}`" />
<div class="innerLayout">
<v-btn
prepend-icon="mdi-arrow-left"
variant="flat"
>
<Link :href="route('profile.achievements', { user: user.name })">
Back to {{ user.name }}'s Achievements
</Link>
</v-btn>
<ButtonLink variant="flat" icon="mdi-arrow-left" :label="`Back to ${user.name}'s Achievements`" :href="route('profile.achievements', { user: user.name })" />
<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,
<Link :href="route('profile.achievement', {user: loggedInUser.name, achievement: achievement.internal_name})">please click here</Link>.
</VAlert>
<Panel>
<div class="achievement-hero">
@@ -110,7 +116,18 @@ const difficultyVariant = computed(() => {
</div>
</Panel>
<component :is="AchievementDetail" :regions="regions" :flights="flights" :achievement="achievement" :user="user" :isFollowing="isFollowing" />
<component
:is="AchievementDetail"
:regions="regions"
:flights="flights"
:achievement="achievement"
:user="user"
:isFollowing="isFollowing"
:airlines="airlines"
:alliance="alliance"
:continents="continents"
:families="aircraft_families"
/>
</div>
</ProfileLayout>
</template>
+4 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import {Head} from "@inertiajs/vue3";
import {Head, Link} from "@inertiajs/vue3";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import {Achievement, Flight, User, UserAchievement} from "@/Types/types";
import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue";
@@ -10,6 +10,7 @@ import AirlinePanel from "@/Components/FlightsGoneBy/Panels/AirlinePanel.vue";
import AircraftPanel from "@/Components/FlightsGoneBy/Panels/AircraftPanel.vue";
import RoutePanel from "@/Components/FlightsGoneBy/Panels/RoutePanel.vue";
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
defineOptions({ layout: MainLayout })
@@ -30,6 +31,8 @@ const props = defineProps<{
<Head :title="`${flight.flight_number ?? user.name + '\'s Flight'}`" />
<div class="flight-profile">
<ButtonLink variant="flat" icon="mdi-arrow-left" :label="`Back to ${user.name}'s Flights`" :href="route('profile.departure-board', { user: user.name, flight: flight.id })" />
<!-- Main grid -->
<div class="profile-grid">
<RoutePanel :flight="flight" />
+2 -1
View File
@@ -21,6 +21,7 @@ const props = defineProps<{
initialView?: ProfileView
isFollowing: boolean
flight_api_url: string
flightCount: number,
}>()
// Flights state
@@ -120,7 +121,7 @@ function switchView(view: ProfileView) {
<template>
<Head :title="`${user.name}'s Flights`" />
<ProfileLayout :is-following="isFollowing" :flightCount="flights.length" :user="user" :loading="flightsLoading">
<ProfileLayout :is-following="isFollowing" :flightCount="flightCount" :user="user" :loading="flightsLoading">
<ProfileViewSwitcher :user="user" :active-view="activeView" @update:active-view="switchView" />
<DepartureBoard v-if="activeView === 'board'" :flight-id="localSelectedFlightId" :flight-stats="stats" :canEdit="canEdit" :user="user" />
<BoardingPasses v-if="activeView === 'passes'" :flight-stats="stats" :canEdit="canEdit" />
+1
View File
@@ -99,6 +99,7 @@ export interface Achievement {
category?: AchievementCategory
difficulty?: AchievementDifficulty
has_page: boolean
sort_order: number
}
export interface UserAchievement {