24 changed files with 619 additions and 279 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ enum FlightNotificationType: string
{ {
return match ($this) { return match ($this) {
self::Booked => "{$user->name} booked a new flight", self::Booked => "{$user->name} booked a new flight",
self::Logged => "{$user->name} logged a new flight", self::Logged => "{$user->name} logged a historic flight",
self::Cancelled => "{$user->name} cancelled a flight", self::Cancelled => "{$user->name} cancelled a flight",
self::Departed => "{$user->name} is taking off!", self::Departed => "{$user->name} is taking off!",
self::Arrived => "{$user->name} has landed!", self::Arrived => "{$user->name} has landed!",
+11
View File
@@ -19,6 +19,7 @@ class AdminController extends Controller
function staticAdminData(){ function staticAdminData(){
return [ return [
'missingLiveryCount' => $this->adminService->getMissingLiveries()->count(), 'missingLiveryCount' => $this->adminService->getMissingLiveries()->count(),
'missingAirlineCount' => $this->adminService->getMissingAirlines()->count(),
]; ];
} }
@@ -44,6 +45,16 @@ class AdminController extends Controller
]); ]);
} }
function reconcileMissingAirlines(){
$missingAirlines = $this->adminService->getMissingAirlines()->values();
return Inertia::render('Admin/MissingAirlines', [
'title' => $missingAirlines->count() . ' Missing Airlines',
'missingAirlines' => $missingAirlines,
...$this->staticAdminData(),
]);
}
public function ignoreMissingLivery(Request $request) public function ignoreMissingLivery(Request $request)
{ {
$validated = $request->validate([ $validated = $request->validate([
@@ -102,6 +102,7 @@ class UserProfileController extends Controller
$canView = Gate::allows('viewProfileData', $user); $canView = Gate::allows('viewProfileData', $user);
$achievements = Achievement::with(['category', 'difficulty']) $achievements = Achievement::with(['category', 'difficulty'])
->orderBy('id', 'asc')
->get() ->get()
->groupBy(fn(Achievement $a) => $a->category->name) ->groupBy(fn(Achievement $a) => $a->category->name)
->map(fn($group) => $group->sortBy('sort_order')->values()); ->map(fn($group) => $group->sortBy('sort_order')->values());
@@ -171,6 +172,11 @@ class UserProfileController extends Controller
default => null, default => null,
}; };
$majorAlliances = match($achievement->internal_name){
'airlines_alliances.all_major_alliances' => Alliance::MAJOR_ALLIANCES,
default => [],
};
$continents = match($achievement->internal_name){ $continents = match($achievement->internal_name){
'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(), 'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(),
default => [], default => [],
@@ -207,6 +213,7 @@ class UserProfileController extends Controller
'continents' => $continents, 'continents' => $continents,
'aircraft_families' => $aircraftFamilies, 'aircraft_families' => $aircraftFamilies,
'achievementCount' => $user->unlockedAchievements()->count(), 'achievementCount' => $user->unlockedAchievements()->count(),
'majorAlliances' => Alliance::whereIn('internal_name', $majorAlliances)->get(),
'canView' => $canView, 'canView' => $canView,
]); ]);
} }
+3
View File
@@ -26,6 +26,9 @@ class Alliance extends Model
'skyteam' => 'airlines_alliances.all_skyteam', 'skyteam' => 'airlines_alliances.all_skyteam',
]; ];
const array MAJOR_ALLIANCES = ['skyteam', 'oneworld', 'star_alliance'];
public function airlines(): HasMany public function airlines(): HasMany
{ {
return $this->hasMany(Airline::class); return $this->hasMany(Airline::class);
+20 -1
View File
@@ -153,7 +153,26 @@ class User extends Authenticatable implements MustVerifyEmail
->toJson(); ->toJson();
}); });
$collection = collect(json_decode($json)); $dateFormat = auth()->user()?->resolved_settings['date_format'] ?? 'j M Y';
$timeFormat = auth()->user()?->resolved_settings['time_format'] === '24hr' ? 'Hi' : 'g:iA';
$collection = collect(json_decode($json))
->map(function ($flight) use ($dateFormat, $timeFormat) {
$departure = Carbon::parse($flight->departure_date)
->setTimezone($flight->departure_airport->timezone);
$arrival = Carbon::parse($flight->arrival_date)
->setTimezone($flight->arrival_airport->timezone);
$flight->departure_date_display = $departure->format($dateFormat);
$flight->departure_time_display = $departure->format($timeFormat);
$flight->arrival_date_display = $arrival->format($dateFormat);
$flight->arrival_time_display = $arrival->format($timeFormat);
return $flight;
});
$today = now('UTC')->toDateString(); $today = now('UTC')->toDateString();
return match ($filter) { return match ($filter) {
@@ -28,6 +28,12 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$check = fn(string $alliance): int => $flownAllianceAirlines->get($alliance, 0); $check = fn(string $alliance): int => $flownAllianceAirlines->get($alliance, 0);
$majorAlliancesFlown = collect(Alliance::MAJOR_ALLIANCES)
->filter(fn(string $alliance) => $check($alliance) > 0)
->count();
$this->awardProgress($majorAlliancesFlown, 'airlines_alliances.all_major_alliances');
$this->awardProgress($check('skyteam'), 'airlines_alliances.all_skyteam'); $this->awardProgress($check('skyteam'), 'airlines_alliances.all_skyteam');
$this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld'); $this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld');
$this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance'); $this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance');
@@ -40,6 +46,8 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count(); $checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count();
$this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3'); $this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3');
$this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3'); $this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3');
$this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3'); $this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3');
+33
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\DTOs\MissingLivery; use App\DTOs\MissingLivery;
use App\Models\IgnoredMissingLivery; use App\Models\IgnoredMissingLivery;
use App\Models\UserFlight; use App\Models\UserFlight;
use Illuminate\Support\Enumerable;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -45,4 +46,36 @@ class AdminService
->sortBy('airline_name') ->sortBy('airline_name')
->values(); ->values();
} }
function getMissingAirlines(): Enumerable|Collection
{
$flights = UserFlight::whereNotNull('flight_number')
->whereNull('airline_id')
->with('user:id,name') // adjust to your actual user relation/columns
->get(['id', 'flight_number', 'user_id']);
return $flights
->map(function ($flight) {
// Extract the 2-character IATA-style prefix (letters and/or digits)
preg_match('/^([A-Za-z0-9]{2})\d+/', trim($flight->flight_number), $matches);
return [
'code' => $matches[1] ?? null,
'flight_number' => $flight->flight_number,
'link' => "/u/{$flight->user->name}/flight/{$flight->id}",
];
})
->filter(fn ($f) => $f['code'] !== null) // drop any that didn't match the pattern
->groupBy('code')
->map(function ($group, $code) {
return [
'code' => $code,
'flights' => $group->map(fn ($f) => [
'flight_number' => $f['flight_number'],
'link' => $f['link'],
])->values()->all(),
];
})
->values();
}
} }
+13
View File
@@ -54,6 +54,19 @@ class SettingsRegistry
['value' => '24hr', 'label' => '24-Hour'], ['value' => '24hr', 'label' => '24-Hour'],
], ],
], ],
[
'key' => 'date_format',
'type' => 'select',
'label' => 'Date Format',
'category' => 'Localization',
'default' => 'j M Y',
'options' => [
['value' => 'j M Y', 'label' => '20 Feb 2015'],
['value' => 'Y-m-d', 'label' => '2015-02-20'],
['value' => 'd/m/Y', 'label' => '20/02/2015'],
['value' => 'm/d/Y', 'label' => '02/20/2015'],
],
],
[ [
'key' => 'default_login_page', 'key' => 'default_login_page',
'type' => 'select', 'type' => 'select',
@@ -0,0 +1,58 @@
<?php
use App\Models\Achievement;
use App\Models\AchievementCategory;
use App\Models\AchievementDifficulty;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$moderate = AchievementDifficulty::where('internal_name', 'moderate')->first();
$airlines_and_alliances = AchievementCategory::where('internal_name', 'airlines_and_alliances')->first();
Achievement::create([
'name' => 'Allied Forces',
'internal_name' => 'airlines_alliances.all_major_alliances',
'short_description' => 'Fly The 3 Major Airline Alliances (Skyteam, Star Alliance, Oneworld)',
'progressive' => true,
'difficulty_description' => 'For most people in most parts of the world this is quite achievable',
'threshold' => 3,
'achievement_category_id' => $airlines_and_alliances->id,
'achievement_difficulty_id' => $moderate->id,
'has_page' => true,
'sort_order' => 1,
'icon' => 'standard_achievement.png',
'long_description' => '',
]);
Achievement::where('internal_name', 'airlines_alliances.cn3')->update(['sort_order' => 3]);
Achievement::where('internal_name', 'airlines_alliances.us3')->update(['sort_order' => 1]);
Achievement::where('internal_name', 'airlines_alliances.me3')->update(['sort_order' => 2]);
Achievement::where('internal_name', 'airlines_alliances.all_major_alliances')->update(['sort_order' => 4]);
Achievement::where('internal_name', 'airlines_alliances.all_skyteam')->update(['sort_order' => 5]);
Achievement::where('internal_name', 'airlines_alliances.all_oneworld')->update(['sort_order' => 6]);
Achievement::where('internal_name', 'airlines_alliances.all_star_alliance')->update(['sort_order' => 7]);
Achievement::where('internal_name', 'airlines_alliances.all_vanilla_alliance')->update(['sort_order' => 8]);
foreach(User::all() as $user){
$user->calculateAchievements();
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { MissingAirline } from "@/Types/types"
defineProps<{
airline: MissingAirline
}>()
</script>
<template>
<div class="missing-airline-card">
<div class="card-header">
<span class="airline-code">{{ airline.code }}</span>
<span class="flight-count">{{ airline.flights.length }} flight{{ airline.flights.length === 1 ? '' : 's' }}</span>
</div>
<ul class="flight-list">
<li v-for="flight in airline.flights" :key="flight.link" class="flight-item">
<a :href="flight.link" class="flight-link" target="_blank" rel="noopener">
{{ flight.flight_number }}
</a>
</li>
</ul>
</div>
</template>
<style scoped>
.missing-airline-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding-bottom: 0.5rem;
}
.airline-code {
font-family: 'Share Tech Mono', monospace;
font-size: 1.1rem;
letter-spacing: 0.08em;
color: #ffc107;
font-weight: 700;
}
.flight-count {
font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #778;
}
.flight-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.flight-item {
display: flex;
}
.flight-link {
font-family: 'Share Tech Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.06em;
color: #9fb3c8;
text-decoration: none;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
padding: 0.25rem 0.5rem;
transition: color 0.15s ease, border-color 0.15s ease;
}
.flight-link:hover {
color: #ffc107;
border-color: rgba(255, 193, 7, 0.4);
}
</style>
@@ -1,125 +1,115 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed } from 'vue'
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types' import { Achievement, Alliance, Flight, User } from '@/Types/types'
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue' import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue' import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue' import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.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 AllianceLogo from '@/Components/FlightsGoneBy/AllianceLogo.vue'
import FlightBadge from "@/Components/FlightsGoneBy/FlightBadge.vue"; import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
defineOptions({ inheritAttrs: false })
interface AirlineEntry {
airline: Airline
flights: Flight[]
}
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
followStatus: string followStatus: string
alliance: Alliance majorAlliances: Alliance[]
airlines: Airline[]
flights: Flight[] flights: Flight[]
}>() }>()
const flightsByAirline = computed(() => { interface AllianceEntry {
const map = new Map<string, AirlineEntry>() alliance: Alliance
const idToKey = new Map<number, string>() flights: Flight[]
}
for (const airline of props.airlines) { const alliances = computed<AllianceEntry[]>(() => {
const key = rowKey(airline) const entries = props.majorAlliances.map(alliance => ({ alliance, flights: [] as Flight[] }))
map.set(key, { airline, flights: [] }) const byName = new Map(entries.map(e => [e.alliance.internal_name, e]))
idToKey.set(airline.id, key)
for (const alias of airline.merged_from ?? []) {
idToKey.set(alias.id, key)
}
}
for (const flight of props.flights) { for (const flight of props.flights) {
const airline = flight.airline const key = flight.airline?.alliance?.internal_name
if (!airline) continue
const key = idToKey.get(airline.id)
if (!key) continue if (!key) continue
map.get(key)?.flights.push(flight) byName.get(key)?.flights.push(flight)
} }
return map return entries
}) })
function rowKey(airline: Airline): string { const completedCount = computed(
return airline.iata_code ?? airline.internal_name () => alliances.value.filter(a => a.flights.length > 0).length
)
function flightTitle(flight: Flight): string {
const name = flight.airline?.name ?? 'Unknown airline'
return `${name}: ${flight.departure_airport?.iata_code}${flight.arrival_airport?.iata_code}`
} }
const rows = computed(() => [...flightsByAirline.value.keys()])
function entryFor(key: string): AirlineEntry {
return flightsByAirline.value.get(key)!
}
</script> </script>
<template> <template>
<!-- Header --> <!-- Header -->
<Panel> <Panel>
<div class="alliance-header"> <div class="alliance-header">
<AllianceLogo :alliance="alliance" size="56" /> <div class="logo-row">
<AllianceLogo
v-for="entry in alliances"
:key="entry.alliance.internal_name"
:alliance="entry.alliance"
size="56"
/>
</div>
<div> <div>
<PanelHeader centered>{{ alliance.name }}</PanelHeader> <PanelHeader centered>Major Alliances</PanelHeader>
<PanelSubHeader centered> <PanelSubHeader centered>
<slot /> {{ completedCount }} / {{ alliances.length }} alliances flown
</PanelSubHeader> </PanelSubHeader>
</div> </div>
</div> </div>
</Panel> </Panel>
<!-- Per-alliance breakdown -->
<!-- Airlines table --> <Panel v-for="entry in alliances" :key="entry.alliance.internal_name">
<Panel>
<div class="table-toolbar"> <div class="table-toolbar">
<PanelHeader>Airlines</PanelHeader> <PanelHeader>{{ entry.alliance.name }}</PanelHeader>
</div> </div>
<BadgeTable <BadgeTable
:rows="rows" :rows="[entry.alliance.internal_name]"
:rowKey="key => key" :rowKey="key => key"
:hasItems="key => entryFor(key).flights.length > 0" :hasItems="() => entry.flights.length > 0"
labelWidth="14em" labelWidth="14em"
> >
<template #label="{ row: key }"> <template #label>
<div class="airline-label" > <div class="airline-label">
<AirlineLogo :airline="entryFor(key).airline" size="24" /> <AllianceLogo :alliance="entry.alliance" size="24" />
<span>{{ entryFor(key).airline.name }}</span> <span>{{ entry.alliance.name }}</span>
</div> </div>
</template> </template>
<template #items="{ row: key }"> <template #items>
<FlightBadge <FlightBadge
v-for="flight in entryFor(key).flights" v-for="flight in entry.flights"
:key="flight.id" :key="flight.id"
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`" :title="flightTitle(flight)"
:flight="flight" /> :flight="flight"
/>
</template> </template>
</BadgeTable> </BadgeTable>
</Panel> </Panel>
<!-- Slot for alliance-specific panels -->
<slot name="extra" />
<!-- Requirements --> <!-- Requirements -->
<Panel> <Panel>
<PanelHeader centered>Requirements</PanelHeader> <PanelHeader centered>Requirements</PanelHeader>
<p> <p>
To complete this challenge you must fly with every current member airline of To complete this challenge you must fly with at least one member airline from each of
<strong>{{ alliance.name }}</strong>. Alliance membership changes over time, so the the three major alliances. Unlike the individual alliance challenges, you don't need
required airlines reflect the current roster. to fly every airline in an alliance just one flight per alliance is enough.
</p> </p>
<p> <p>
Codeshare flights do not count, the operating carrier must be a member of {{alliance.name}}. Codeshare flights do not count, the operating carrier must be a member of the
alliance.
</p> </p>
</Panel> </Panel>
</template> </template>
@@ -133,6 +123,10 @@ function entryFor(key: string): AirlineEntry {
flex-wrap: wrap; flex-wrap: wrap;
} }
.logo-row {
display: flex;
gap: 0.75rem;
}
.table-toolbar { .table-toolbar {
display: flex; display: flex;
@@ -142,7 +136,6 @@ function entryFor(key: string): AirlineEntry {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.airline-label { .airline-label {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -150,5 +143,4 @@ function entryFor(key: string): AirlineEntry {
gap: 0.5rem; gap: 0.5rem;
padding: 0 0.25rem; padding: 0 0.25rem;
} }
</style> </style>
@@ -12,7 +12,6 @@ import { usePage } from "@inertiajs/vue3";
import { User } from "@/Types/types"; import { User } from "@/Types/types";
import Mono from "@/Components/FlightsGoneBy/Mono.vue"; import Mono from "@/Components/FlightsGoneBy/Mono.vue";
import DayDifference from "@/Components/FlightsGoneBy/DayDifference.vue"; import DayDifference from "@/Components/FlightsGoneBy/DayDifference.vue";
import Time from "@/Components/FlightsGoneBy/Time.vue";
const props = defineProps<{ const props = defineProps<{
flight: Flight flight: Flight
@@ -99,12 +98,12 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
<td v-if="showColumn('departure_time')" class="v-data-table__td"> <td v-if="showColumn('departure_time')" class="v-data-table__td">
<span class="time-cell"> <span class="time-cell">
<Mono small><Time :value="flight.departure_time_display" /></Mono> <Mono small>{{flight.departure_time_display}}</Mono>
</span> </span>
</td> </td>
<td v-if="showColumn('arrival_time')" class="v-data-table__td arrival-time-cell"> <td v-if="showColumn('arrival_time')" class="v-data-table__td arrival-time-cell">
<Mono small><Time :value="flight.arrival_time_display" /></Mono> <Mono small>{{flight.arrival_time_display}}</Mono>
<DayDifference :value="flight.arrival_day_difference" /> <DayDifference :value="flight.arrival_day_difference" />
</td> </td>
@@ -659,7 +659,7 @@ export default defineComponent({
const fitBounds = (): void => { const fitBounds = (): void => {
if (!props.flights.length) return if (!props.flights.length) return
const padding = props.compact ? 25 : 60 const padding = props.compact ? 50 : 60
const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg]) const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg])
const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg]) const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg])
@@ -1,42 +0,0 @@
<script setup lang="ts">
import {computed} from "vue";
import {usePage} from "@inertiajs/vue3";
import {SharedProps} from "@/Types/types";
const props = defineProps<{
value: string; //Time in g:iA format
}>()
const page = usePage<SharedProps>().props;
const timeFormatString = page.auth.user?.resolved_settings?.time_format
const formattedTime = computed(() => {
if (timeFormatString !== "24hr") {
return props.value;
}
const match = props.value.match(/^(\d{1,2}):(\d{2})(AM|PM)$/i);
if (!match) {
return props.value;
}
let [, hourStr, minuteStr, meridiem] = match;
let hour = parseInt(hourStr, 10);
if (meridiem.toUpperCase() === "PM" && hour !== 12) {
hour += 12;
} else if (meridiem.toUpperCase() === "AM" && hour === 12) {
hour = 0;
}
return `${hour.toString().padStart(2, "0")}${minuteStr}`;
});
</script>
<template>
<span>{{ formattedTime }}</span>
</template>
<style scoped>
</style>
@@ -16,7 +16,7 @@ const store = useNotificationStore();
max-width="360" max-width="360"
> >
<v-card-text class="d-flex align-center ga-3"> <v-card-text class="d-flex align-center ga-3">
<v-icon icon="mdi-trophy" color="amber" size="32" /> <v-icon :icon="notification.is_achievement ? 'mdi-trophy' : 'mdi-bell-ring'" color="amber" size="32" />
<div> <div>
<div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div> <div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div>
<div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div> <div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div>
+76 -164
View File
@@ -41,95 +41,9 @@ const isEdit = !!props.flight
// ── User format preferences ───────────────────────────────────────────────── // ── User format preferences ─────────────────────────────────────────────────
const timeFormat = computed(() => page.auth?.user?.resolved_settings?.time_format ?? 'ampm') const timeFormat = computed(() => page.auth?.user?.resolved_settings?.time_format ?? 'ampm')
// day.js/date-fns-style tokens, e.g. 'D MMM YYYY' -> "7 Dec 2026". Not user-configurable yet.
const dateFormat = computed(() => 'DD-MM-YYYY')
// ── Date token formatting/parsing helpers (D, DD, M, MM, MMM, MMMM, YY, YYYY) ──
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n: number) => String(n).padStart(2, '0')
const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
const MONTHS_LONG = ['January','February','March','April','May','June','July','August','September','October','November','December']
// Formats a Date according to a token format string. Tokens matched longest-first
// so e.g. "YYYY" isn't partially consumed by a lone "Y"-style rule.
function formatDateForFormat(d: Date | null, format: string): string {
if (!d) return ''
return format.replace(/YYYY|YY|MMMM|MMM|MM|M|DD|D/g, (token) => {
switch (token) {
case 'YYYY': return String(d.getFullYear())
case 'YY': return String(d.getFullYear()).slice(-2)
case 'MMMM': return MONTHS_LONG[d.getMonth()]
case 'MMM': return MONTHS_SHORT[d.getMonth()]
case 'MM': return pad(d.getMonth() + 1)
case 'M': return String(d.getMonth() + 1)
case 'DD': return pad(d.getDate())
case 'D': return String(d.getDate())
default: return token
}
})
}
// Extracts the ordered list of D/M/Y tokens from a format string, e.g.
// "D MMM YYYY" -> ['D', 'MMM', 'YYYY']
function getFormatTokens(format: string): string[] {
return format.match(/YYYY|YY|MMMM|MMM|MM|M|DD|D/g) ?? ['D', 'MMM', 'YYYY']
}
// Parses user-typed text into a Date, following the token order of `format`.
// Splits on any run of non-alphanumeric characters, so "/", "-", ".", and
// spaces are all accepted as separators regardless of what the format uses.
// Month tokens (MMM/MMMM) match against month names case-insensitively.
function parseDateInputStr(input: string, format: string): Date | null {
const trimmed = input.trim()
if (!trimmed) return null
const parts = trimmed.split(/[^a-zA-Z0-9]+/).filter(Boolean)
const tokens = getFormatTokens(format)
if (parts.length !== tokens.length) return null
let day: number | null = null
let month: number | null = null // 0-indexed
let year: number | null = null
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
const part = parts[i]
if (token === 'D' || token === 'DD') {
const val = parseInt(part, 10)
if (isNaN(val)) return null
day = val
} else if (token === 'M' || token === 'MM') {
const val = parseInt(part, 10)
if (isNaN(val)) return null
month = val - 1
} else if (token === 'MMM' || token === 'MMMM') {
const lower = part.toLowerCase()
const idx = MONTHS_LONG.findIndex(m => m.toLowerCase().startsWith(lower) || lower.startsWith(m.slice(0, 3).toLowerCase()))
if (idx === -1) return null
month = idx
} else if (token === 'YYYY') {
const val = parseInt(part, 10)
if (isNaN(val)) return null
year = val
} else if (token === 'YY') {
const val = parseInt(part, 10)
if (isNaN(val)) return null
year = val < 100 ? 2000 + val : val
}
}
if (day === null || month === null || year === null) return null
const date = new Date(year, month, day)
// Reject dates that overflowed (e.g. day 31 in a 30-day month)
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
return null
}
return date
}
// ── Flight number lookup ────────────────────────────────────────────────────── // ── Flight number lookup ──────────────────────────────────────────────────────
const flightNumber = ref(props.flight?.flight_number ?? '') const flightNumber = ref(props.flight?.flight_number ?? '')
@@ -397,47 +311,36 @@ const arrivalMax = computed(() => getArrivalBound(form, 3))
const arrivalMinDate = computed(() => arrivalMin.value ? new Date(arrivalMin.value) : undefined) const arrivalMinDate = computed(() => arrivalMin.value ? new Date(arrivalMin.value) : undefined)
const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.value) : undefined) const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.value) : undefined)
// ── Editable, format-aware date text inputs ───────────────────────────────── // ── Native date inputs ───────────────────────────────────────────────────────
// departureDatePart / arrivalDatePart stay the source of truth as Date // departureDatePart / arrivalDatePart stay the source of truth as Date
// objects. These input refs are what the text-fields actually bind to, so // objects. These computed get/set pairs are what the native <input type=date>
// the user can type freely; parsing only commits on blur/enter. // elements bind to — the browser handles locale display, typing, and
// validation itself, so no custom format-aware parsing is needed here.
// The Vuetify v-date-picker is still available via a separate calendar icon
// for users who prefer to pick from a calendar rather than type.
const departureDateInput = ref(formatDateForFormat(departureDatePart.value, dateFormat.value)) function toIsoDateString(d: Date | null): string {
const arrivalDateInput = ref(formatDateForFormat(arrivalDatePart.value, dateFormat.value)) if (!d) return ''
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
watch([departureDatePart, dateFormat], () => {
departureDateInput.value = formatDateForFormat(departureDatePart.value, dateFormat.value)
})
watch([arrivalDatePart, dateFormat], () => {
arrivalDateInput.value = formatDateForFormat(arrivalDatePart.value, dateFormat.value)
})
function commitDepartureDateInput() {
if (!departureDateInput.value.trim()) {
departureDatePart.value = null
return
}
const parsed = parseDateInputStr(departureDateInput.value, dateFormat.value)
if (parsed) {
departureDatePart.value = parsed
} else {
departureDateInput.value = formatDateForFormat(departureDatePart.value, dateFormat.value)
}
} }
function commitArrivalDateInput() { function fromIsoDateString(value: string): Date | null {
if (!arrivalDateInput.value.trim()) { if (!value) return null
arrivalDatePart.value = null const [y, m, d] = value.split('-').map(Number)
return if (!y || !m || !d) return null
} return new Date(y, m - 1, d)
const parsed = parseDateInputStr(arrivalDateInput.value, dateFormat.value)
if (parsed) {
arrivalDatePart.value = parsed
} else {
arrivalDateInput.value = formatDateForFormat(arrivalDatePart.value, dateFormat.value)
}
} }
const departureDateValue = computed({
get: () => toIsoDateString(departureDatePart.value),
set: (value: string) => { departureDatePart.value = fromIsoDateString(value) },
})
const arrivalDateValue = computed({
get: () => toIsoDateString(arrivalDatePart.value),
set: (value: string) => { arrivalDatePart.value = fromIsoDateString(value) },
})
// ── Editable, format-aware time text fields ──────────────────────────────── // ── Editable, format-aware time text fields ────────────────────────────────
// departureTimePart / arrivalTimePart stay the source of truth as internal // departureTimePart / arrivalTimePart stay the source of truth as internal
// "HH:mm" (24hr) strings. These input refs are what the text-fields actually // "HH:mm" (24hr) strings. These input refs are what the text-fields actually
@@ -569,28 +472,30 @@ function commitArrivalTimeInput() {
<!-- Departure date + time --> <!-- Departure date + time -->
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="depDateMenu" :close-on-content-click="false"> <v-text-field
<template #activator="{ props: menuProps }"> v-model="departureDateValue"
<v-text-field type="date"
v-bind="menuProps" label="Departure Date"
v-model="departureDateInput" prepend-inner-icon="mdi-calendar"
label="Departure Date" :disabled="!lookupComplete"
clearable :error-messages="submitForm.errors.departure_date"
prepend-inner-icon="mdi-calendar" >
:placeholder="dateFormat" <template #append-inner>
:disabled="!lookupComplete" <v-menu v-model="depDateMenu" :close-on-content-click="false">
:error-messages="submitForm.errors.departure_date" <template #activator="{ props: menuProps }">
@click:clear="clearDepartureDate" <v-icon
@blur="commitDepartureDateInput" v-bind="menuProps"
@keydown.enter="commitDepartureDateInput" icon="mdi-calendar-month"
autocomplete="false" style="cursor: pointer"
/> />
</template>
<v-date-picker
v-model="departureDatePart"
@update:model-value="depDateMenu = false"
/>
</v-menu>
</template> </template>
<v-date-picker </v-text-field>
v-model="departureDatePart"
@update:model-value="depDateMenu = false"
/>
</v-menu>
</v-col> </v-col>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="depTimeMenu" :close-on-content-click="false"> <v-menu v-model="depTimeMenu" :close-on-content-click="false">
@@ -626,29 +531,32 @@ function commitArrivalTimeInput() {
<!-- Arrival date + time --> <!-- Arrival date + time -->
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="arrDateMenu" :close-on-content-click="false"> <v-text-field
<template #activator="{ props: menuProps }"> v-model="arrivalDateValue"
<v-text-field type="date"
v-bind="menuProps" label="Arrival Date"
v-model="arrivalDateInput" prepend-inner-icon="mdi-calendar"
label="Arrival Date" :disabled="!lookupComplete"
clearable :error-messages="submitForm.errors.arrival_date"
prepend-inner-icon="mdi-calendar" >
:placeholder="dateFormat" <template #append-inner>
:disabled="!lookupComplete" <v-menu v-model="arrDateMenu" :close-on-content-click="false">
:error-messages="submitForm.errors.arrival_date" <template #activator="{ props: menuProps }">
@click:clear="clearArrivalDate" <v-icon
@blur="commitArrivalDateInput" v-bind="menuProps"
@keydown.enter="commitArrivalDateInput" icon="mdi-calendar-month"
/> style="cursor: pointer"
/>
</template>
<v-date-picker
v-model="arrivalDatePart"
:min="arrivalMinDate"
:max="arrivalMaxDate"
@update:model-value="arrDateMenu = false"
/>
</v-menu>
</template> </template>
<v-date-picker </v-text-field>
v-model="arrivalDatePart"
:min="arrivalMinDate"
:max="arrivalMaxDate"
@update:model-value="arrDateMenu = false"
/>
</v-menu>
</v-col> </v-col>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="arrTimeMenu" :close-on-content-click="false"> <v-menu v-model="arrTimeMenu" :close-on-content-click="false">
@@ -860,4 +768,8 @@ function commitArrivalTimeInput() {
</template> </template>
<style scoped> <style scoped>
:deep(input[type="date"]::-webkit-calendar-picker-indicator) {
display: none;
-webkit-appearance: none;
}
</style> </style>
+2 -1
View File
@@ -7,6 +7,7 @@ import {Head} from "@inertiajs/vue3";
defineProps<{ defineProps<{
title: string; title: string;
missingLiveryCount: number; missingLiveryCount: number;
missingAirlineCount: number;
}>() }>()
</script> </script>
@@ -15,7 +16,7 @@ defineProps<{
<MainLayout> <MainLayout>
<Head :title="title" /> <Head :title="title" />
<div class="admin-container"> <div class="admin-container">
<AdminSidebar :missingLiveryCount="missingLiveryCount" /> <AdminSidebar :missingAirlineCount="missingAirlineCount" :missingLiveryCount="missingLiveryCount" />
<div class="admin-content"> <div class="admin-content">
<GlassBox class="admin-page" :title="title"> <GlassBox class="admin-page" :title="title">
<slot /> <slot />
@@ -3,6 +3,7 @@ import {Link} from "@inertiajs/vue3";
import {number} from "echarts"; import {number} from "echarts";
defineProps<{ defineProps<{
missingLiveryCount: number; missingLiveryCount: number;
missingAirlineCount: number;
}>() }>()
</script> </script>
@@ -18,6 +19,11 @@ defineProps<{
Reconcile Missing Liveries Reconcile Missing Liveries
<v-chip size="x-small" color="error" class="ml-1">{{ missingLiveryCount }}</v-chip> <v-chip size="x-small" color="error" class="ml-1">{{ missingLiveryCount }}</v-chip>
</Link> </Link>
<Link :href="route('admin.reconcile-missing-airlines')" class="sidebar-link">
<v-icon icon="mdi-airplane-takeoff" size="18" />
Reconcile Missing Airlines
<v-chip size="x-small" color="error" class="ml-1">{{ missingAirlineCount }}</v-chip>
</Link>
</aside> </aside>
</template> </template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import GlassBox from "@/Components/FlightsGoneBy/GlassBox.vue";
import { Head } from "@inertiajs/vue3";
import { MissingAirline } from "@/Types/types";
import MissingAirlineCard from "@/Components/Admin/MissingAirlineCard.vue";
import AdminLayout from "@/Pages/Admin/AdminLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
defineOptions({ layout: AdminLayout });
defineProps<{
missingAirlines: MissingAirline[]
}>()
</script>
<template>
<Head title="Missing Airlines" />
<Panel>
<template #header>
<h1 class="panel-title">Missing Airlines</h1>
<span class="panel-subtitle">
{{ missingAirlines.length }} unmatched code{{ missingAirlines.length === 1 ? '' : 's' }}
</span>
</template>
<div v-if="missingAirlines.length === 0" class="empty-state">
No missing airlines 🎉
</div>
<div v-else class="missing-airlines-grid">
<MissingAirlineCard
v-for="airline in missingAirlines"
:key="airline.code"
:airline="airline"
/>
</div>
</Panel>
</template>
<style scoped>
.panel-title {
font-family: 'Share Tech Mono', monospace;
font-size: 1.25rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #e6e9ef;
}
.panel-subtitle {
font-family: 'Share Tech Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #778;
}
.missing-airlines-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.empty-state {
font-family: 'Share Tech Mono', monospace;
font-size: 0.85rem;
color: #778;
padding: 2rem 0;
text-align: center;
}
</style>
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, Alliance, Flight, User } from '@/Types/types'
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import AllianceLogo from '@/Components/FlightsGoneBy/AllianceLogo.vue'
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
achievement: Achievement
user: User
followStatus: string
majorAlliances: Alliance[]
flights: Flight[]
}>()
interface AllianceEntry {
alliance: Alliance
flights: Flight[]
}
const alliances = computed<AllianceEntry[]>(() => {
const entries = props.majorAlliances.map(alliance => ({ alliance, flights: [] as Flight[] }))
const byName = new Map(entries.map(e => [e.alliance.internal_name, e]))
for (const flight of props.flights) {
const key = flight.airline?.alliance?.internal_name
if (!key) continue
byName.get(key)?.flights.push(flight)
}
return entries
})
const rows = computed(() => alliances.value.map(e => e.alliance.internal_name))
function entryFor(key: string): AllianceEntry {
return alliances.value.find(e => e.alliance.internal_name === key)!
}
const completedCount = computed(
() => alliances.value.filter(a => a.flights.length > 0).length
)
function flightTitle(flight: Flight): string {
const name = flight.airline?.name ?? 'Unknown airline'
return `${name}: ${flight.departure_airport?.iata_code}${flight.arrival_airport?.iata_code}`
}
</script>
<template>
<!-- Header -->
<Panel>
<div class="alliance-header">
<div class="logo-row">
<AllianceLogo
v-for="entry in alliances"
:key="entry.alliance.internal_name"
:alliance="entry.alliance"
size="56"
/>
</div>
</div>
</Panel>
<!-- Alliances table -->
<Panel>
<div class="table-toolbar">
<PanelHeader>Alliances</PanelHeader>
</div>
<BadgeTable
:rows="rows"
:rowKey="key => key"
:hasItems="key => entryFor(key).flights.length > 0"
labelWidth="14em"
>
<template #label="{ row: key }">
<div class="airline-label">
<AllianceLogo :alliance="entryFor(key).alliance" size="24" />
<span>{{ entryFor(key).alliance.name }}</span>
</div>
</template>
<template #items="{ row: key }">
<FlightBadge
v-for="flight in entryFor(key).flights"
:key="flight.id"
:title="flightTitle(flight)"
:flight="flight"
/>
</template>
</BadgeTable>
</Panel>
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly with at least one member airline from each of
the three major alliances. Unlike the individual alliance challenges, you don't need
to fly every airline in an alliance just one flight per alliance is enough.
</p>
<p>
Codeshare flights do not count, the operating carrier must be a member of the
alliance.
</p>
</Panel>
</template>
<style scoped>
.alliance-header {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.logo-row {
display: flex;
gap: 0.75rem;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.airline-label {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.25rem;
}
</style>
@@ -26,6 +26,7 @@ const props = defineProps<{
airlines: Airline[] airlines: Airline[]
continents: Continent[] continents: Continent[]
aircraft_families: Record<string, string[]> aircraft_families: Record<string, string[]>
majorAlliances: Alliance[]
achievementCount: number achievementCount: number
canView: boolean canView: boolean
}>() }>()
@@ -63,7 +64,7 @@ const unlocked = computed(() => {
<VAlert type="info" v-if="loggedInUser?.id !== user.id && loggedInUser"> <VAlert type="info" v-if="loggedInUser?.id !== user.id && loggedInUser">
You are viewing {{user.name}}'s progress in this achievement. If you would like to see your progress, please You are viewing {{user.name}}'s progress in this achievement. If you would like to see your progress, please
<Link :href="route('profile.achievement', {user: loggedInUser.name, achievement: achievement.internal_name})"><VBtn variant="flat">click here.</VBtn></Link> <Link :href="route('profile.achievement', {user: loggedInUser.name, achievement: achievement.internal_name})"><u>click here.</u></Link>
</VAlert> </VAlert>
@@ -116,6 +117,7 @@ const unlocked = computed(() => {
:alliance="alliance" :alliance="alliance"
:continents="continents" :continents="continents"
:families="aircraft_families" :families="aircraft_families"
:major-alliances="majorAlliances"
/> />
</div> </div>
</ProfileLayout> </ProfileLayout>
-1
View File
@@ -191,7 +191,6 @@ watch(() => form.flight_reason, (val) => console.log('flight_reason changed:', v
<v-select v-model="form.seat_type" label="Seat Type" :items="flight.seat_types" :error-messages="submitForm.errors.seat_type_id" /> <v-select v-model="form.seat_type" label="Seat Type" :items="flight.seat_types" :error-messages="submitForm.errors.seat_type_id" />
</v-col> </v-col>
<v-col cols="12" md="6"> <v-col cols="12" md="6">
{{console.log(form.flight_reason)}}
<v-select v-model="form.flight_reason" label="Reason" :items="flight.flight_reasons" :error-messages="submitForm.errors.flight_reason_id" /> <v-select v-model="form.flight_reason" label="Reason" :items="flight.flight_reasons" :error-messages="submitForm.errors.flight_reason_id" />
</v-col> </v-col>
</v-row> </v-row>
+8
View File
@@ -328,6 +328,14 @@ export interface MissingLivery {
clipboard_text: string; clipboard_text: string;
} }
export interface MissingAirline{
code: string;
flights: {
flight_number: string;
link: string;
}[]
}
declare module '@inertiajs/vue3' { declare module '@inertiajs/vue3' {
interface PageProps extends SharedProps {} interface PageProps extends SharedProps {}
} }
+2
View File
@@ -40,6 +40,8 @@ use Inertia\Inertia;
Route::get('/', [AdminController::class, 'dashboard'])->name('dashboard'); Route::get('/', [AdminController::class, 'dashboard'])->name('dashboard');
Route::get('/missing-liveries', [AdminController::class, 'reconcileMissingLiveries'])->name('reconcile-missing-liveries')->middleware('permission:reconcile_missing_liveries'); Route::get('/missing-liveries', [AdminController::class, 'reconcileMissingLiveries'])->name('reconcile-missing-liveries')->middleware('permission:reconcile_missing_liveries');
Route::post('/ignore-missing-livery', [AdminController::class, 'ignoreMissingLivery'])->name('ignore-missing-livery'); Route::post('/ignore-missing-livery', [AdminController::class, 'ignoreMissingLivery'])->name('ignore-missing-livery');
Route::get('/missing-airlines', [AdminController::class, 'reconcileMissingAirlines'])->name('reconcile-missing-airlines')->middleware('permission:reconcile_missing_liveries');
}); });