34 Commits
Author SHA1 Message Date
dredgy 8832028304 Following Select Box Shows only When User Is Following People 2026-07-22 21:46:02 +10:00
dredgy 7cecdb6bde Added engine count/type filters 2026-07-22 21:25:54 +10:00
dredgy 39c1f78b20 Compact map padding improved 2026-07-22 19:08:27 +10:00
dredgy 923d9a2c3e Added user select box to achievement pages 2026-07-21 21:03:46 +10:00
dredgy 3abd66001b Fixed Chart Axes 2026-07-20 20:34:43 +10:00
dredgy fe642300b6 Bug fix for individual alliance challenges 2026-07-20 15:59:15 +10:00
dredgy 693290095d Merge pull request 'Added icons for non-achievement notifications' (#9) from add-alliance-challenge into main
Reviewed-on: #9
2026-07-20 14:54:02 +10:00
dredgy f63b54733b Added icons for non-achievement notifications 2026-07-20 14:49:05 +10:00
dredgy 866b45b01d Merge pull request 'Add alliance challenge' (#8) from add-alliance-challenge into main
Reviewed-on: #8
2026-07-20 14:43:48 +10:00
dredgy 45b4b49b83 Merge pull request 'Added concentric donut charts and added an alliance breakdown' (#7) from update-donut-charts into main
Reviewed-on: #7
2026-07-20 14:43:30 +10:00
dredgy b3047e9571 Added a challenge for flying the 3 major airline alliances 2026-07-20 14:39:37 +10:00
dredgy 267fd6fd12 Added a challenge for flying the 3 major airline alliances 2026-07-20 14:33:32 +10:00
dredgy 8464fa11d4 Added concentric donut charts and added an alliance breakdown 2026-07-20 13:38:50 +10:00
dredgy 60a3bce3bf Merge pull request 'Allow custom date time formats' (#6) from allow-custom-date-time-formats into main
Reviewed-on: #6
2026-07-18 00:47:41 +10:00
dredgy 39c0ce1b44 Added custom date format 2026-07-18 00:47:14 +10:00
dredgy 792cb082fd Merge remote-tracking branch 'origin/main' into allow-custom-date-time-formats 2026-07-18 00:46:47 +10:00
dredgy ae77fec0da Merge pull request 'Compact map padding amended' (#5) from fix-map-padding into main
Reviewed-on: #5
2026-07-18 00:46:37 +10:00
dredgy 2a5d2d7875 Compact map padding amended 2026-07-17 23:43:16 +10:00
dredgy dbb3dac7f6 Fixed Bug With Shared Flights 2026-07-17 23:40:44 +10:00
dredgy 731a6aa09a Merge pull request 'Add missing airline check' (#4) from add-missing-airline-check into main
Reviewed-on: #4
2026-07-15 11:36:47 +10:00
dredgy dc476fedd6 Merge remote-tracking branch 'origin/main' into add-missing-airline-check 2026-07-15 11:36:07 +10:00
dredgy a11e8f1428 Fixed Bug With Shared Flights 2026-07-12 18:33:01 +10:00
dredgy 90630489df Fixed Bug With Shared Flights 2026-07-12 17:52:27 +10:00
dredgy 2d55aa8488 Add preliminary missing airline check 2026-07-12 01:50:44 +10:00
dredgy fd3b5b0405 Add preliminary missing airline check 2026-07-12 01:42:57 +10:00
dredgy 1f23cbc033 Merge pull request 'Add Meta Description to App' (#3) from add-meta-description-to-app into main
Reviewed-on: #3
2026-07-12 01:16:36 +10:00
dredgy e9ad1b45e0 Merge branch 'main' into add-meta-description-to-app 2026-07-12 01:16:28 +10:00
dredgy 64e164887b Merge pull request 'Add Version Number To Footer' (#2) from add-version-number-to-footer into main
Reviewed-on: #2
2026-07-12 01:16:17 +10:00
dredgy 4b8fb1119b Merge branch 'main' into add-version-number-to-footer 2026-07-12 01:16:08 +10:00
dredgy 0230c77a2a Add Meta Description to App 2026-07-12 01:15:29 +10:00
dredgy f0306a6dd8 Add Version Number To Footer 2026-07-12 01:11:45 +10:00
dredgy 35c439dba4 Add Version Number To Footer 2026-07-12 01:05:37 +10:00
dredgy 3a082a57ff Merge pull request 'Add Date and Time Picker Separate' (#1) from split-date-and-time-pickers into main
Reviewed-on: #1
2026-07-12 00:57:20 +10:00
dredgy c016a4aaaf Add Date and Time Picker Separate 2026-07-12 00:56:23 +10:00
48 changed files with 1499 additions and 159 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ enum FlightNotificationType: string
{
return match ($this) {
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::Departed => "{$user->name} is taking off!",
self::Arrived => "{$user->name} has landed!",
+11
View File
@@ -19,6 +19,7 @@ class AdminController extends Controller
function staticAdminData(){
return [
'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)
{
$validated = $request->validate([
@@ -102,6 +102,7 @@ class UserProfileController extends Controller
$canView = Gate::allows('viewProfileData', $user);
$achievements = Achievement::with(['category', 'difficulty'])
->orderBy('id', 'asc')
->get()
->groupBy(fn(Achievement $a) => $a->category->name)
->map(fn($group) => $group->sortBy('sort_order')->values());
@@ -171,6 +172,11 @@ class UserProfileController extends Controller
default => null,
};
$majorAlliances = match($achievement->internal_name){
'airlines_alliances.all_major_alliances' => Alliance::MAJOR_ALLIANCES,
default => [],
};
$continents = match($achievement->internal_name){
'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(),
default => [],
@@ -207,6 +213,7 @@ class UserProfileController extends Controller
'continents' => $continents,
'aircraft_families' => $aircraftFamilies,
'achievementCount' => $user->unlockedAchievements()->count(),
'majorAlliances' => Alliance::whereIn('internal_name', $majorAlliances)->get(),
'canView' => $canView,
]);
}
@@ -38,6 +38,7 @@ class HandleInertiaRequests extends Middleware
'roles' => $request->user()?->getRoleNames() ?? [],
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
'apiToken' => session('api_token'),
'following' => $request->user()?->following()->with('followee')->get()->pluck('followee')->filter()->pluck('name')->toArray() ?? [],
],
'flash' => [
'success' => $request->session()->get('success'),
+3
View File
@@ -26,6 +26,9 @@ class Alliance extends Model
'skyteam' => 'airlines_alliances.all_skyteam',
];
const array MAJOR_ALLIANCES = ['skyteam', 'oneworld', 'star_alliance'];
public function airlines(): HasMany
{
return $this->hasMany(Airline::class);
+20 -1
View File
@@ -153,7 +153,26 @@ class User extends Authenticatable implements MustVerifyEmail
->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();
return match ($filter) {
@@ -28,6 +28,12 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$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('oneworld'), 'airlines_alliances.all_oneworld');
$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();
$this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3');
$this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3');
$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\Models\IgnoredMissingLivery;
use App\Models\UserFlight;
use Illuminate\Support\Enumerable;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Collection;
@@ -45,4 +46,36 @@ class AdminService
->sortBy('airline_name')
->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();
}
}
+37 -12
View File
@@ -13,24 +13,13 @@ class SettingsRegistry
'AI Generated Content' => 'Airline tail logos and liveries are AI generated with human cleanup. If you would rather not see any AI, then our blank aircraft templates are human created.',
'Account & Privacy' => 'Everything to do with your account.',
'Notifications' => 'What you want to hear about (or not!)',
'Localization' => 'Units of measurement, date and time preferences.',
];
}
public static function schema(): array
{
return [
[
'key' => 'distance_unit',
'type' => 'select',
'label' => 'Distance Units',
'category' => 'FlightsGoneBy Settings',
'default' => 'km',
'options' => [
['value' => 'km', 'label' => 'Kilometres (km)'],
['value' => 'mi', 'label' => 'Miles (mi)'],
['value' => 'nm', 'label' => 'Nautical miles (nm)'],
],
],
[
'category' => 'Account & Privacy',
'key' => 'profile_privacy',
@@ -42,6 +31,42 @@ class SettingsRegistry
['value' => 'private', 'label' => 'Private Profile Viewable Only By You and Your Approved Followers'],
]
],
[
'key' => 'distance_unit',
'type' => 'select',
'label' => 'Distance Units',
'category' => 'Localization',
'default' => 'km',
'options' => [
['value' => 'km', 'label' => 'Kilometres (km)'],
['value' => 'mi', 'label' => 'Miles (mi)'],
['value' => 'nm', 'label' => 'Nautical miles (nm)'],
],
],
[
'key' => 'time_format',
'type' => 'select',
'label' => 'Time Format',
'category' => 'Localization',
'default' => 'ampm',
'options' => [
['value' => 'ampm', 'label' => 'AM/PM'],
['value' => '24hr', 'label' => '24-Hour'],
],
],
[
'key' => 'date_format',
'type' => 'select',
'label' => 'Date Format',
'category' => 'Localization',
'default' => 'j M Y',
'options' => [
['value' => 'j M Y', 'label' => '20 Feb 2015'],
['value' => 'Y-m-d', 'label' => '2015-02-20'],
['value' => 'd/m/Y', 'label' => '20/02/2015'],
['value' => 'm/d/Y', 'label' => '02/20/2015'],
],
],
[
'key' => 'default_login_page',
'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
{
//
}
};
+4
View File
@@ -12,6 +12,10 @@ COPY . .
RUN composer install --no-dev --optimize-autoloader
ARG APP_VERSION=Dev
ENV VITE_APP_VERSION=$APP_VERSION
LABEL org.opencontainers.image.version=$APP_VERSION
RUN npm install && npm run build && rm -f public/hot
COPY docker/nginx.conf /etc/nginx/nginx.conf
+1 -1
View File
@@ -8,7 +8,7 @@
"@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5",
"chart.js": "^4.5.1",
"echarts": "^6.0.0",
"echarts": "^6.1.0",
"flag-icons": "^7.5.0",
"maplibre-gl": "^5.22.0",
"pinia": "^3.0.4",
+2 -2
View File
@@ -8,7 +8,7 @@
"dev": "vite",
"dev:all": "concurrently -s all \"php artisan serve\" \"php artisan reverb:start\" \"php artisan queue:work\" \"npm run dev\" > /dev/null 2>&1",
"serve:all": "concurrently -s all \"php artisan serve\" \"php artisan reverb:start\" \"php artisan queue:work\" > /dev/null 2>&1",
"updateVersion": "cross-var docker build -t dredgy/flights-api:$npm_config_tag -t dredgy/flights-api:latest . && cross-var docker push dredgy/flights-api:$npm_config_tag && docker push dredgy/flights-api:latest"
"updateVersion": "cross-var docker build --no-cache --build-arg APP_VERSION=$npm_config_tag -t dredgy/flights-api:$npm_config_tag -t dredgy/flights-api:latest . && cross-var docker push dredgy/flights-api:$npm_config_tag && docker push dredgy/flights-api:latest"
},
"devDependencies": {
"@inertiajs/vue3": "^2.0.0",
@@ -41,7 +41,7 @@
"@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5",
"chart.js": "^4.5.1",
"echarts": "^6.0.0",
"echarts": "^6.1.0",
"flag-icons": "^7.5.0",
"maplibre-gl": "^5.22.0",
"pinia": "^3.0.4",
@@ -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>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue";
import {FlightStats} from "@/Composables/useFlightStats";
const props = defineProps<{
flightStats: FlightStats
}>()
</script>
<template>
<DonutChart
title="Alliances"
:height="280"
:labels="flightStats.alliances.value.labels"
:series="flightStats.alliances.value.series"
total-label="Alliances"
center-mode="count"
/>
</template>
<style scoped>
</style>
@@ -1,80 +1,283 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ChartType } from '@/Types/types'
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue"
import VueApexCharts from "vue3-apexcharts"
import { computed, nextTick, onMounted, ref } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent])
const chartRef = ref<InstanceType<typeof VChart>>()
onMounted(() => {
// vue-echarts' autoresize can measure the container before the page's
// own layout (flex/grid) has settled, leaving the chart off-center until
// something else triggers a resize. Force one right after mount.
nextTick(() => chartRef.value?.resize())
})
type SeriesEntry = { name: string; data: number[] }
interface RingConfig {
name: string
series: number[]
colors: string[]
}
const props = defineProps<{
title: string
height: number | string
series: number[]
labels: string[]
// Either a flat array (single ring, e.g. Flight Types) or an array of
// named series (multi ring, e.g. Continents: [{name:'Flights',data},{name:'Upcoming',data}])
series: number[] | SeriesEntry[]
total?: number
options?: object
totalLabel?: string
// 'sum' (default): center shows the total of all values (e.g. 308 flights).
// 'count': center shows how many categories have a non-zero value (e.g. 6
// continents) — for charts where summing the slices double-counts things,
// like a flight touching two continents.
centerMode?: 'sum' | 'count'
}>()
const chartOptions = computed(() => ({
chart: {
type: 'donut',
background: 'transparent',
fontFamily: 'inherit',
animations: { enabled: false },
},
theme: { mode: 'dark' },
labels: props.labels,
colors: ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4', 'pink'],
dataLabels: {
enabled: true,
formatter: (val: number) => `${Math.round(val)}%`,
style: { fontSize: '12px', fontWeight: 400 },
dropShadow: { enabled: false },
},
plotOptions: {
pie: {
donut: {
size: '60%',
labels: {
show: true,
total: {
show: true,
label: 'Total',
fontSize: '13px',
color: '#556677',
formatter: () => (props.total ?? props.series.reduce((a, b) => a + b, 0)).toString(),
},
value: {
fontSize: '22px',
fontWeight: 500,
color: '#e0e6f0',
},
},
},
},
},
legend: {
position: 'bottom',
labels: { colors: '#778899' },
markers: { width: 8, height: 8, radius: 2 },
itemMargin: { horizontal: 12, vertical: 4 },
},
stroke: { width: 0 },
tooltip: {
theme: 'dark',
y: { formatter: (val: number) => `${val} flights` },
},
}))
// Same color for the same label across every ring (matches the legend),
// distinguished visually by which ring they're in rather than by shade.
const PALETTE = ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4', 'pink']
// Build rings from `series`, dropping any ring that's entirely zero
// (e.g. a continent with no upcoming flights at all -> no "Upcoming" ring).
const normalizedRings = computed<RingConfig[]>(() => {
const raw = props.series
if (!raw || !raw.length) return []
if (typeof raw[0] === 'number') {
return [{ name: props.title, series: raw as number[], colors: PALETTE }]
}
return (raw as SeriesEntry[])
.map((entry) => ({ name: entry.name, series: entry.data, colors: PALETTE }))
.filter((ring) => ring.series.some((v) => v > 0))
})
const isMulti = computed(() => normalizedRings.value.length > 1)
const mode = ref<'all' | string>('all')
const modeOptions = computed(() => [
{ key: 'all', label: normalizedRings.value.length === 2 ? 'Both' : 'All' },
...normalizedRings.value.map((r) => ({ key: r.name, label: `${r.name} only` })),
])
// Skip zero-value slices entirely, rather than drawing an invisible sliver with a stray label/border.
function toSeriesData(ring: RingConfig) {
return ring.series
.map((value, i) => ({
name: props.labels[i] ?? `Item ${i + 1}`,
value,
itemStyle: { color: ring.colors[i % ring.colors.length] },
}))
.filter((d) => d.value > 0)
}
function ringCount(ring: RingConfig) {
return ring.series.filter((v) => v > 0).length
}
function ringSum(ring: RingConfig) {
return ring.series.reduce((a, b) => a + b, 0)
}
const centerLabel = computed(() => {
const aggregate = props.centerMode === 'count' ? ringCount : ringSum
if (mode.value !== 'all') {
const ring = normalizedRings.value.find((r) => r.name === mode.value)!
return { title: ring.name, value: aggregate(ring) }
}
if (props.centerMode === 'count') {
// Distinct categories with a non-zero value in ANY ring (not summed
// across rings, since a continent visited on both legs is still one continent).
const present = new Set<string>()
normalizedRings.value.forEach((ring) => {
ring.series.forEach((value, i) => {
if (value > 0) present.add(props.labels[i] ?? `Item ${i + 1}`)
})
})
return { title: props.totalLabel ?? 'Total', value: present.size }
}
return {
title: props.totalLabel ?? 'Total',
value: props.total ?? normalizedRings.value.reduce((sum, r) => sum + ringSum(r), 0),
}
})
const activeRings = computed(() =>
mode.value === 'all' ? normalizedRings.value : normalizedRings.value.filter((r) => r.name === mode.value)
)
// Same radius bands as the original working sample:
// - isolated (single ring, or one toggled on its own): 55%80%
// - nested pair: outer 68%88%, inner 32%62% (leaves a roomy center hole)
// - 3+ rings: evenly spaced bands, falling back to a generous hole
function ringRadius(i: number, n: number, isolated: boolean): [number, number] {
if (isolated) return [55, 80]
if (n === 2) return i === 0 ? [68, 88] : [42, 62]
const edge = 88
const hole = 30
const gap = 6
const bandWidth = (edge - hole - gap * (n - 1)) / n
const outer = edge - i * (bandWidth + gap)
const inner = outer - bandWidth
return [inner, outer]
}
const numericHeight = computed(() => (typeof props.height === 'number' ? props.height : parseInt(String(props.height), 10) || 280))
interface SliceLabel {
key: string
x: number
y: number
text: string
}
// Computed independently of ECharts' own label placement (see chartOption below
// for why): for each visible slice, find its mid-angle and the ring's
// mid-radius, then convert to a pixel offset from the chart's center using
// plain trig. ECharts pies start at 90° (12 o'clock) and sweep clockwise,
// which in standard (counter-clockwise-positive) math angle terms means the
// angle decreases as cumulative value increases.
const sliceLabels = computed<SliceLabel[]>(() => {
const n = normalizedRings.value.length
const isolated = mode.value !== 'all' || n === 1
const chartRadiusPx = numericHeight.value / 2
const labels: SliceLabel[] = []
activeRings.value.forEach((ring) => {
const i = normalizedRings.value.findIndex((r) => r.name === ring.name)
const [innerPct, outerPct] = ringRadius(i, n, isolated)
const midRadiusPx = ((innerPct + outerPct) / 2 / 100) * chartRadiusPx
const total = ring.series.reduce((a, b) => a + b, 0)
if (!total) return
let cumulative = 0
ring.series.forEach((value, idx) => {
const percent = (value / total) * 100
const midFraction = (cumulative + value / 2) / total
cumulative += value
if (value <= 0 || percent < 5) return
const angleDeg = 90 - midFraction * 360
const angleRad = (angleDeg * Math.PI) / 180
labels.push({
key: `${ring.name}-${props.labels[idx] ?? idx}`,
x: midRadiusPx * Math.cos(angleRad),
y: -midRadiusPx * Math.sin(angleRad),
text: `${Math.round(percent)}%`,
})
})
})
return labels
})
const chartOption = computed(() => {
const n = normalizedRings.value.length
const isolated = mode.value !== 'all' || n === 1
const series = activeRings.value.map((ring) => {
const i = normalizedRings.value.findIndex((r) => r.name === ring.name)
const [innerPct, outerPct] = ringRadius(i, n, isolated)
return {
name: ring.name,
type: 'pie',
radius: [`${innerPct}%`, `${outerPct}%`],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderColor: 'transparent', borderWidth: 0 },
// ECharts' own label placement (both 'inside' and 'outside' + negative
// distance) proved unreliable here, so labels are rendered separately
// as plain positioned HTML — see `sliceLabels` below.
label: { show: false },
labelLine: { show: false },
data: toSeriesData(ring),
}
})
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: '#0f1b33',
borderColor: '#2a3a5c',
textStyle: { color: '#e0e6f0', fontSize: 12 },
formatter: (p: any) =>
`<b>${p.seriesName} · ${p.name}</b><br/>${p.value} flights (${Math.round(p.percent)}%)`,
},
series,
}
})
const legendItems = computed(() => {
const first = normalizedRings.value[0]
if (!first) return []
return props.labels.map((label, i) => ({ label, color: first.colors[i % first.colors.length] }))
})
</script>
<template>
<div class="chart-wrap">
<div class="chart-title">{{ title }}</div>
<VueApexCharts
type="donut"
:height="height"
:options="options ?? chartOptions"
:series="series"
/>
<template v-if="isMulti">
<div class="toggle-row">
<button
v-for="opt in modeOptions"
:key="opt.key"
class="toggle-btn"
:class="{ active: mode === opt.key }"
@click="mode = opt.key"
>
{{ opt.label }}
</button>
</div>
<div v-if="mode === 'all'" class="ring-labels">
<span v-for="(r, i) in normalizedRings" :key="r.name">
{{ i === 0 ? 'Outer' : i === normalizedRings.length - 1 ? 'Inner' : `Ring ${i + 1}` }}
Ring = {{ r.name }}
</span>
</div>
</template>
<div class="donut-container" :style="{ height: typeof height === 'number' ? `${height}px` : height }">
<VChart ref="chartRef" class="chart" :option="chartOption" autoresize />
<div class="center-label">
<div class="center-label-title">{{ centerLabel.title }}</div>
<div class="center-label-value">{{ centerLabel.value }}</div>
</div>
<div
v-for="lbl in sliceLabels"
:key="lbl.key"
class="slice-label"
:style="{ transform: `translate(calc(-50% + ${lbl.x}px), calc(-50% + ${lbl.y}px))` }"
>
{{ lbl.text }}
</div>
</div>
<div v-if="legendItems.length" class="legend">
<div v-for="item in legendItems" :key="item.label" class="legend-item">
<span class="dot" :style="{ background: item.color }"></span>
<span>{{ item.label }}</span>
</div>
</div>
</div>
</template>
@@ -82,6 +285,7 @@ const chartOptions = computed(() => ({
.chart-wrap {
display: flex;
flex-direction: column;
width: 100%;
gap: 4px;
}
@@ -93,11 +297,99 @@ const chartOptions = computed(() => ({
letter-spacing: 0.08em;
}
.chart-hidden {
visibility: hidden;
.donut-container {
position: relative;
width: 100%;
}
:deep(svg) {
outline: none;
.chart {
width: 100%;
height: 100%;
}
.center-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
}
.center-label-title {
font-size: 13px;
color: #556677;
}
.center-label-value {
font-size: 22px;
font-weight: 500;
color: #e0e6f0;
}
.slice-label {
position: absolute;
top: 50%;
left: 50%;
color: #fff;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
}
.toggle-row {
display: flex;
justify-content: center;
gap: 8px;
margin: 4px 0 4px;
}
.toggle-btn {
background: transparent;
border: 1px solid #2a3a5c;
color: #778899;
border-radius: 20px;
padding: 5px 12px;
font-size: 11px;
cursor: pointer;
}
.toggle-btn.active {
background: #1e3a6a;
color: #e0e6f0;
border-color: #1e3a6a;
}
.ring-labels {
display: flex;
justify-content: center;
gap: 20px;
font-size: 11px;
color: #556677;
margin-bottom: 4px;
}
.legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px;
margin-top: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #778899;
}
.dot {
width: 8px;
height: 8px;
border-radius: 2px;
display: inline-block;
}
</style>
@@ -1,18 +1,22 @@
<script setup lang="ts">
import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue";
import {FlightStats} from "@/Composables/useFlightStats";
import { FlightStats } from "@/Composables/useFlightStats";
const props = defineProps<{
flightStats: FlightStats
}>()
</script>
<template>
<DonutChart
title="Continents"
:height="280"
:labels="flightStats.continents.value.labels"
:series="flightStats.continents.value.series"
total-label="Continents"
center-mode="count"
/>
</template>
<style scoped>
<style scoped>
</style>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280"
:labels="flightStats.classes.value.labels"
:series="flightStats.classes.value.series"
total-label="Flight Classes"
center-mode="count"
/>
</template>
<style scoped>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280"
:labels="flightStats.reasons.value.labels"
:series="flightStats.reasons.value.series"
total-label="Reasons"
center-mode="count"
/>
</template>
<style scoped>
@@ -7,8 +7,9 @@ const props = defineProps<{
</script>
<template>
<DonutChart
title="Flight Types"
title="Flight Scope"
:height="280"
total-label="Flights"
:labels="flightStats.flightTypes.value.labels"
:series="flightStats.flightTypes.value.series"
/>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280"
:labels="flightStats.seatTypes.value.labels"
:series="flightStats.seatTypes.value.series"
center-mode="count"
total-label="Seat Types"
/>
</template>
<style scoped>
@@ -52,7 +52,7 @@ const chartEvents = computed(() => ({
<InlineBadge v-if="index !== null" variant="generic">{{ aircraft[index].designator }}</InlineBadge>
</div>
<div class="ct-row">
<span class="ct-label">Flights</span>
<span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? aircraft[index].past : '' }}</span>
</div>
<div v-if="index !== null && aircraft[index].upcoming" class="ct-row">
@@ -52,7 +52,7 @@ const chartEvents = computed(() => ({
{{ index !== null ? airlines[index].name : '' }}
</div>
<div class="ct-row">
<span class="ct-label">Flights</span>
<span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? airlines[index].past : '' }}</span>
</div>
<div v-if="index !== null && airlines[index].upcoming" class="ct-row">
@@ -37,7 +37,7 @@ const series = computed(() => props.flightStats.topRoutes.value.series)
<ChartTooltip :visible="visible" :x="x" :y="y">
<div class="ct-name">{{ index !== null ? routes[index].label : '' }}</div>
<div class="ct-row">
<span class="ct-label">Flights</span>
<span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? routes[index].past : '' }}</span>
</div>
<div v-if="index !== null && routes[index].upcoming" class="ct-row">
@@ -98,12 +98,12 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
<td v-if="showColumn('departure_time')" class="v-data-table__td">
<span class="time-cell">
<Mono small>{{ flight.departure_time_display }}</Mono>
<Mono small>{{flight.departure_time_display}}</Mono>
</span>
</td>
<td v-if="showColumn('arrival_time')" class="v-data-table__td arrival-time-cell">
<Mono small>{{ flight.arrival_time_display }}</Mono>
<Mono small>{{flight.arrival_time_display}}</Mono>
<DayDifference :value="flight.arrival_day_difference" />
</td>
@@ -160,7 +160,7 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
<td v-if="showColumn('class_seat_combined')" class="v-data-table__td class-badges-cell">
<span class="class-cell">
<CrewTooltip v-if="flight.flight_reason?.name == 'Crew'" :crew-type="flight.crew_type!">
<CrewTooltip v-if="flight.flight_reason?.name == 'Crew' && flight.crew_type" :crew-type="flight.crew_type!">
<FlightClassBadge v-if="flight.flight_class?.internal_name === 'crew'" :flight="flight" />
<InlineBadge v-else variant="crew">Crew</InlineBadge>
</CrewTooltip>
@@ -15,6 +15,7 @@ import TopRoutesChart from '@/Components/FlightsGoneBy/Charts/TopRoutesChart.vue
import FlightTypeChart from '@/Components/FlightsGoneBy/Charts/FlightTypeChart.vue'
import {FlightStats} from "@/Composables/useFlightStats";
import TopAircraftChart from "@/Components/FlightsGoneBy/Charts/TopAircraftChart.vue";
import AllianceChart from "@/Components/FlightsGoneBy/Charts/AllianceChart.vue";
defineProps<{
flightStats: FlightStats
@@ -56,11 +57,14 @@ defineProps<{
<!-- Airlines + Airports -->
<div class="flight-charts glass charts-row">
<TopAirlinesChart :flight-stats="flightStats" />
<TopAirportsChart :flight-stats="flightStats" />
<div class="charts-stack">
<AllianceChart :flight-stats="flightStats" />
</div>
</div>
<!-- Routes + International vs Domestic -->
<div class="flight-charts glass charts-row">
<TopAirportsChart :flight-stats="flightStats" />
<TopRoutesChart :flight-stats="flightStats" />
<TopAircraftChart :flight-stats="flightStats" />
</div>
@@ -23,6 +23,8 @@ const emit = defineEmits<{
seatTypes: number[]
manufacturers: string[]
aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[]
otherUsers: string[]
}]
@@ -62,6 +64,8 @@ function buildOptions(flights: Flight[]) {
const regionRanges = new Set<RegionRange>()
const manufacturers = new Map<string, { name: string }>()
const aircraftModels = new Map<number, { id: number; name: string }>()
const engineTypes = new Map<string, { name: string }>()
const engineCounts = new Map<number, { count: number }>()
const airportRegions = new Map<number, { id: number; name: string; countryCodes: Set<string> }>()
const otherUsers = new Set<string>()
@@ -120,6 +124,12 @@ function buildOptions(flights: Flight[]) {
if (f.aircraft?.id != null && f.aircraft?.display_name_short)
aircraftModels.set(f.aircraft.id, { id: f.aircraft.id, name: f.aircraft.display_name_short })
if (f.aircraft?.engine_type)
engineTypes.set(f.aircraft.engine_type, { name: f.aircraft.engine_type })
if (f.aircraft?.engine_count != null)
engineCounts.set(f.aircraft.engine_count, { count: f.aircraft.engine_count })
flightScopes.add(f.scope)
flightRanges.add(f.range)
regionRanges.add(f.region_range)
@@ -144,6 +154,8 @@ function buildOptions(flights: Flight[]) {
regionRanges: regionRangeOrder.filter(r => regionRanges.has(r)).map(r => ({ value: r, label: REGION_RANGE_LABELS[r] })),
manufacturers: [...manufacturers.values()].sort((a, b) => a.name.localeCompare(b.name)),
aircraftModels: [...aircraftModels.values()].sort((a, b) => a.name.localeCompare(b.name)),
engineTypes: [...engineTypes.values()].sort((a, b) => a.name.localeCompare(b.name)),
engineCounts: [...engineCounts.values()].sort((a, b) => a.count - b.count),
airportRegions: [...airportRegions.values()].sort((a, b) => a.name.localeCompare(b.name)),
otherUsers: [...otherUsers].sort((a, b) => a.localeCompare(b)),
}
@@ -167,6 +179,8 @@ const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedEngineTypes = ref<string[]>([])
const selectedEngineCounts = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([])
@@ -197,6 +211,8 @@ function emitFilters() {
seatTypes: selectedSeatTypes.value,
manufacturers: selectedManufacturers.value,
aircraftModels: selectedAircraftModels.value,
engineTypes: selectedEngineTypes.value,
engineCounts: selectedEngineCounts.value,
airportRegions: selectedAirportRegions.value,
otherUsers: selectedOtherUsers.value,
})
@@ -496,6 +512,42 @@ const countryFlagClass = (code: string) =>
</template>
</v-select>
<v-select
v-if="availableOptions.engineTypes.length > 0"
v-model="selectedEngineTypes"
:items="availableOptions.engineTypes"
item-title="name" item-value="name"
label="Engine Type"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedEngineTypes.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedEngineTypes.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.engineCounts.length > 0"
v-model="selectedEngineCounts"
:items="availableOptions.engineCounts"
item-title="count" item-value="count"
label="Engine Count"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).count }}<span v-if="index < Math.min(selectedEngineCounts.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedEngineCounts.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.crewTypes.length > 0"
v-model="selectedCrewTypes"
@@ -659,7 +659,9 @@ export default defineComponent({
const fitBounds = (): void => {
if (!props.flights.length) return
const padding = props.compact ? 25 : 60
const padding = props.compact
? { top: 20, bottom: 20, left: 55, right: 55 }
: { top: 60, bottom: 60, left: 60, right: 60 }
const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg])
const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg])
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { usePage, router } from "@inertiajs/vue3";
import { SharedProps } from "@/Types/types";
const props = withDefaults(
defineProps<{
redirectRoute: string;
routeParams?: Record<string, string | number>;
placeholder?: string;
}>(),
{
routeParams: () => ({}),
}
);
const page = usePage<SharedProps>().props;
const following = [...page.auth?.following ?? [], page.auth?.user?.name]
.filter((x, i, a) => a.indexOf(x) === i);
function onUpdate(user: string) {
if (!user) return;
router.get(
route(props.redirectRoute, {
user,
...props.routeParams,
})
);
}
</script>
<template>
<VSelect
v-if="following.length > 1"
:placeholder="placeholder"
variant="outlined"
density="comfortable"
:items="following"
@update:model-value="onUpdate"
>
</VSelect>
</template>
<style scoped>
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
withDefaults(defineProps<{
title: string,
title?: string,
wide?: boolean,
blurb?: string
}>(), { wide: false })
@@ -25,5 +25,4 @@ defineProps<{
background-size: cover;
min-width: 300px;
}
</style>
@@ -4,12 +4,15 @@ import { SharedProps } from "@/Types/types"
const page = usePage<SharedProps>().props
const year = new Date().getFullYear()
const version = __APP_VERSION__
</script>
<template>
<footer class="glass">
<span class="footer-copyright">
&copy; {{ year }} FlightsGoneBy
<span class="footer-version">v{{ version }}</span>
</span>
<span class="footer-links">
@@ -73,4 +76,9 @@ footer {
font-size: 0.6rem;
}
}
.footer-version {
opacity: 0.8;
margin-left: 0.4em;
}
</style>
@@ -16,7 +16,7 @@ const store = useNotificationStore();
max-width="360"
>
<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 class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div>
<div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div>
+86 -35
View File
@@ -89,32 +89,51 @@ export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
// ── Grouping helpers ──────────────────────────────────────────────────────────
function groupByName(flights: Flight[], accessor: (f: Flight) => string) {
const counts = new Map<string, number>()
flights.forEach(f => {
const key = accessor(f) ?? 'Unknown'
counts.set(key, (counts.get(key) ?? 0) + 1)
})
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1])
function groupByNameSplit(flights: Flight[], upcomingFlights: Flight[], accessor: (f: Flight) => string) {
const countOne = (list: Flight[]) => {
const counts = new Map<string, number>()
list.forEach(f => {
const key = accessor(f) ?? 'Unknown'
counts.set(key, (counts.get(key) ?? 0) + 1)
})
return counts
}
const past = countOne(flights)
const upcoming = countOne(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
const sorted = [...allNames]
.map(name => ({
name,
past: past.get(name) ?? 0,
upcoming: upcoming.get(name) ?? 0,
}))
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
return {
labels: sorted.map(([name]) => name),
series: sorted.map(([, count]) => count),
labels: sorted.map(s => s.name),
series: [
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
}
export function getFlightReasons(flights: Flight[]) {
const result = groupByName(flights, f => f.flight_reason?.name ?? 'Unknown')
return result
export function getFlightReasons(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.flight_reason?.name ?? 'Unknown')
}
export function getFlightClasses(flights: Flight[]) {
const result = groupByName(flights, f => f.flight_class?.name ?? 'Unknown')
return result
export function getFlightClasses(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.flight_class?.name ?? 'Unknown')
}
export function getSeatTypes(flights: Flight[]) {
const result = groupByName(flights, f => f.seat_type?.name ?? 'Unknown')
return result
export function getSeatTypes(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.seat_type?.name ?? 'Unknown')
}
export function getAlliances(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.airline?.alliance?.name ?? 'No Alliance')
}
// ── Countries ─────────────────────────────────────────────────────────────────
@@ -156,7 +175,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
const result = {
countries: sorted,
series: [
{ name: 'Flights', data: sorted.map(s => s.past) },
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
@@ -165,7 +184,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
// ── Continents ────────────────────────────────────────────────────────────────
export function getContinents(flights: Flight[]) {
function countContinents(flights: Flight[]) {
const counts = new Map<string, number>()
flights.forEach(f => {
const continents = new Set<string>()
@@ -175,10 +194,28 @@ export function getContinents(flights: Flight[]) {
if (arr) continents.add(arr)
continents.forEach(c => counts.set(c, (counts.get(c) ?? 0) + 1))
})
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1])
return counts
}
export function getContinents(flights: Flight[], upcomingFlights: Flight[]) {
const past = countContinents(flights)
const upcoming = countContinents(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
const sorted = [...allNames]
.map(name => ({
name,
past: past.get(name) ?? 0,
upcoming: upcoming.get(name) ?? 0,
}))
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
const result = {
labels: sorted.map(([name]) => name),
series: sorted.map(([, count]) => count),
labels: sorted.map(s => s.name),
series: [
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
return result
}
@@ -215,7 +252,7 @@ export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
const result = {
airlines: sorted,
series: [
{ name: 'Flights', data: sorted.map(s => s.past) },
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
@@ -338,7 +375,7 @@ export function getTopAircraft(flights: Flight[], upcomingFlights: Flight[]) {
const result = {
aircraft: sorted,
series: [
{ name: 'Flights', data: sorted.map(s => s.past) },
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
@@ -391,7 +428,7 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
const result = {
routes: sorted,
series: [
{ name: 'Flights', data: sorted.map(s => s.past) },
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
@@ -400,7 +437,7 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
// ── Flight types ──────────────────────────────────────────────────────────────
export function getFlightTypes(flights: Flight[]) {
function countFlightTypes(flights: Flight[]) {
const counts = { International: 0, Domestic: 0 }
flights.forEach(f => {
const dep = f.departure_airport.region?.country?.id
@@ -409,10 +446,22 @@ export function getFlightTypes(flights: Flight[]) {
dep === arr ? counts.Domestic++ : counts.International++
}
})
const sorted = Object.entries(counts).filter(([, count]) => count > 0)
return counts
}
export function getFlightTypes(flights: Flight[], upcomingFlights: Flight[]) {
const past = countFlightTypes(flights)
const upcoming = countFlightTypes(upcomingFlights)
const names = ['International', 'Domestic'] as const
const relevant = names.filter(name => past[name] > 0 || upcoming[name] > 0)
const result = {
labels: sorted.map(([name]) => name),
series: sorted.map(([, count]) => count),
labels: relevant,
series: [
{ name: 'Flown', data: relevant.map(name => past[name]) },
{ name: 'Upcoming', data: relevant.map(name => upcoming[name]) },
],
}
return result
}
@@ -443,16 +492,17 @@ export function useFlightStats(flights: Ref<Flight[]>) {
const perYear = computed(() => getFlightsPerYear(pastFlights.value, upcomingFlights.value))
const perMonth = computed(() => getFlightsPerMonth(pastFlights.value, upcomingFlights.value))
const perDay = computed(() => getFlightsPerDay(pastFlights.value, upcomingFlights.value))
const reasons = computed(() => getFlightReasons(allFlights.value))
const classes = computed(() => getFlightClasses(allFlights.value))
const seatTypes = computed(() => getSeatTypes(allFlights.value))
const reasons = computed(() => getFlightReasons(pastFlights.value, upcomingFlights.value))
const classes = computed(() => getFlightClasses(pastFlights.value, upcomingFlights.value))
const seatTypes = computed(() => getSeatTypes(pastFlights.value, upcomingFlights.value))
const alliances = computed(() => getAlliances(pastFlights.value, upcomingFlights.value))
const countries = computed(() => getCountries(pastFlights.value, upcomingFlights.value))
const continents = computed(() => getContinents(allFlights.value))
const continents = computed(() => getContinents(pastFlights.value, upcomingFlights.value))
const topAirlines = computed(() => getTopAirlines(pastFlights.value, upcomingFlights.value))
const topAirports = computed(() => getTopAirports(pastFlights.value, upcomingFlights.value))
const topAircraft = computed(() => getTopAircraft(pastFlights.value, upcomingFlights.value))
const topRoutes = computed(() => getTopRoutes(pastFlights.value, upcomingFlights.value))
const flightTypes = computed(() => getFlightTypes(allFlights.value))
const flightTypes = computed(() => getFlightTypes(pastFlights.value, upcomingFlights.value))
return {
pastFlights,
@@ -463,6 +513,7 @@ export function useFlightStats(flights: Ref<Flight[]>) {
reasons,
classes,
seatTypes,
alliances,
countries,
continents,
flightTypes,
+343 -23
View File
@@ -1,15 +1,16 @@
<script setup lang="ts">
import MainLayout from '@/Layouts/MainLayout.vue'
import GlassBox from '@/Components/FlightsGoneBy/GlassBox.vue'
import { Head, useForm } from '@inertiajs/vue3'
import {Head, useForm, usePage} from '@inertiajs/vue3'
import AirlineSearchBox from '@/Components/FlightsGoneBy/AirlineSearchBox.vue'
import AircraftSearchBox from '@/Components/FlightsGoneBy/AircraftSearchBox.vue'
import AirportSearchBox from '@/Components/FlightsGoneBy/AirportSearchBox.vue'
import type {SeatType, FlightReason, FlightClass, CrewType} from '@/Types/types'
import type {SeatType, FlightReason, FlightClass, CrewType, SharedProps} from '@/Types/types'
import { ref, watch, computed } from 'vue'
import { VTimePicker, VDatePicker } from 'vuetify/components'
defineOptions({ layout: MainLayout })
const page = usePage<SharedProps>().props
const props = defineProps<{
flight?: {
@@ -38,6 +39,11 @@ const props = defineProps<{
const isEdit = !!props.flight
// ── User format preferences ─────────────────────────────────────────────────
const timeFormat = computed(() => page.auth?.user?.resolved_settings?.time_format ?? 'ampm')
const pad = (n: number) => String(n).padStart(2, '0')
// ── Flight number lookup ──────────────────────────────────────────────────────
const flightNumber = ref(props.flight?.flight_number ?? '')
@@ -91,6 +97,20 @@ async function lookupFlight() {
if (!form.aircraft) form.aircraft = data.aircraft_options[0]
}
// Populate scheduled times if the API returned them and the user
// hasn't already entered a time themselves. Suppress the +1hr
// auto-fill guess while doing this, since we have real data.
suppressArrivalAutoFill = true
const scheduledDep = normalizeTimeString(data.scheduled_departure_time)
if (scheduledDep && !departureTimePart.value) {
departureTimePart.value = scheduledDep
}
const scheduledArr = normalizeTimeString(data.scheduled_arrival_time)
if (scheduledArr && !arrivalTimePart.value) {
arrivalTimePart.value = scheduledArr
}
suppressArrivalAutoFill = false
lookupKey.value++
} catch (e) {
lookupError.value = String(e)
@@ -188,19 +208,98 @@ const fromOptionsData = ref<{ value: number; title: string; country_code: st
const toOptionsData = ref<{ value: number; title: string; country_code: string }[]>(props.flight?.to_options ?? [])
const aircraftOptionsData = ref<{ value: number; title: string }[]>(props.flight?.aircraft_options ?? [])
// ── Split date/time pickers ───────────────────────────────────────────────────
// form.departure_date / form.arrival_date stay as the single source of truth
// (YYYY-MM-DDTHH:mm strings) that the rest of the component and submit() rely
// on. These refs + watchers just give the template two separate pickers while
// keeping those combined strings in sync — no server-side changes needed.
watch(() => form.departure_date, (newVal) => {
if (!newVal || form.arrival_date) return
const dep = new Date(newVal)
function parseDateTime(dt: string | null | undefined) {
if (!dt) return { date: null as Date | null, time: null as string | null }
const d = new Date(dt)
if (isNaN(d.getTime())) return { date: null as Date | null, time: null as string | null }
return {
date: new Date(d.getFullYear(), d.getMonth(), d.getDate()),
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
}
}
function combineDateTime(date: Date | null, time: string | null): string {
if (!date) return ''
const [hh, mm] = (time ?? '00:00').split(':')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${(hh ?? '00').padStart(2, '0')}:${(mm ?? '00').padStart(2, '0')}`
}
function clearDepartureDate() { departureDatePart.value = null }
function clearDepartureTime() { departureTimePart.value = null }
function clearArrivalDate() { arrivalDatePart.value = null }
function clearArrivalTime() { arrivalTimePart.value = null }
// Normalizes whatever shape the API sends a scheduled time in ("14:30",
// "14:30:00", or a full datetime string) down to the internal "HH:mm" format.
function normalizeTimeString(t: string | null | undefined): string | null {
if (!t) return null
const match = t.match(/(\d{1,2}):(\d{2})/)
if (match) {
return `${match[1].padStart(2, '0')}:${match[2]}`
}
const d = new Date(t)
if (!isNaN(d.getTime())) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
}
return null
}
const depInit = parseDateTime(form.departure_date)
const arrInit = parseDateTime(form.arrival_date)
const departureDatePart = ref<Date | null>(depInit.date)
const departureTimePart = ref<string | null>(depInit.time)
const arrivalDatePart = ref<Date | null>(arrInit.date)
const arrivalTimePart = ref<string | null>(arrInit.time)
const depDateMenu = ref(false)
const depTimeMenu = ref(false)
const arrDateMenu = ref(false)
const arrTimeMenu = ref(false)
// A short guard so programmatic updates (e.g. auto-fill of arrival) don't
// fight with the "only auto-fill once" behaviour below.
let suppressArrivalAutoFill = false
// Keep form.departure_date in sync whenever either split piece changes
watch([departureDatePart, departureTimePart], () => {
form.departure_date = combineDateTime(departureDatePart.value, departureTimePart.value)
})
// Keep form.arrival_date in sync whenever either split piece changes
watch([arrivalDatePart, arrivalTimePart], () => {
if (suppressArrivalAutoFill) return
form.arrival_date = combineDateTime(arrivalDatePart.value, arrivalTimePart.value)
})
// Auto-fill arrival (+1hr) the first time a departure TIME is chosen (and a
// departure date is already set) and arrival is still empty. Picking the
// departure date alone no longer triggers this — only setting the time does.
watch(departureTimePart, (newTime) => {
if (suppressArrivalAutoFill) return
if (!newTime || !departureDatePart.value || arrivalDatePart.value) return
const dep = new Date(departureDatePart.value)
const [h, m] = newTime.split(':').map(Number)
dep.setHours(h, m)
const arr = new Date(dep.getTime() + 60 * 60 * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
form.arrival_date = `${arr.getFullYear()}-${pad(arr.getMonth() + 1)}-${pad(arr.getDate())}T${pad(arr.getHours())}:${pad(arr.getMinutes())}`
})
suppressArrivalAutoFill = true
arrivalDatePart.value = new Date(arr.getFullYear(), arr.getMonth(), arr.getDate())
arrivalTimePart.value = `${pad(arr.getHours())}:${pad(arr.getMinutes())}`
suppressArrivalAutoFill = false
form.arrival_date = combineDateTime(arrivalDatePart.value, arrivalTimePart.value)
}, { flush: 'sync' })
const getArrivalBound = (form: any, offsetDays: number) => {
if (!form.departure_date) return undefined
const pad = (n: number) => String(n).padStart(2, '0')
const d = new Date(form.departure_date)
d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
@@ -208,6 +307,127 @@ const getArrivalBound = (form: any, offsetDays: number) => {
const arrivalMin = computed(() => getArrivalBound(form, -2))
const arrivalMax = computed(() => getArrivalBound(form, 3))
const arrivalMinDate = computed(() => arrivalMin.value ? new Date(arrivalMin.value) : undefined)
const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.value) : undefined)
// ── Native date inputs ───────────────────────────────────────────────────────
// departureDatePart / arrivalDatePart stay the source of truth as Date
// objects. These computed get/set pairs are what the native <input type=date>
// 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.
function toIsoDateString(d: Date | null): string {
if (!d) return ''
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
function fromIsoDateString(value: string): Date | null {
if (!value) return null
const [y, m, d] = value.split('-').map(Number)
if (!y || !m || !d) return null
return new Date(y, m - 1, d)
}
const 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 ────────────────────────────────
// departureTimePart / arrivalTimePart stay the source of truth as internal
// "HH:mm" (24hr) strings. These input refs are what the text-fields actually
// bind to, so the user can type freely; parsing only commits on blur/enter.
function formatTimeForFormat(t: string | null, format: string): string {
if (!t) return ''
const [hStr, mStr] = t.split(':')
let h = parseInt(hStr, 10)
if (isNaN(h)) return ''
if (format === '24hr') {
return `${pad(h)}:${mStr}`
}
const ampm = h >= 12 ? 'PM' : 'AM'
let h12 = h % 12
if (h12 === 0) h12 = 12
return `${h12}:${mStr} ${ampm}`
}
function parseTimeInput(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
// ampm form: "2:30 PM", "2:30PM", "2 PM", "2PM"
let match = trimmed.match(/^(\d{1,2}):?(\d{2})?\s*([AaPp][Mm])$/)
if (match) {
let h = parseInt(match[1], 10)
const m = match[2] ? parseInt(match[2], 10) : 0
const ampm = match[3].toUpperCase()
if (h >= 1 && h <= 12 && m >= 0 && m <= 59) {
if (ampm === 'PM' && h !== 12) h += 12
if (ampm === 'AM' && h === 12) h = 0
return `${pad(h)}:${pad(m)}`
}
return null
}
// 24hr form: "14:30" or "1430"
match = trimmed.match(/^(\d{1,2}):?(\d{2})$/)
if (match) {
const h = parseInt(match[1], 10)
const m = parseInt(match[2], 10)
if (h >= 0 && h <= 23 && m >= 0 && m <= 59) {
return `${pad(h)}:${pad(m)}`
}
}
return null
}
const departureTimeInput = ref(formatTimeForFormat(departureTimePart.value, timeFormat.value))
const arrivalTimeInput = ref(formatTimeForFormat(arrivalTimePart.value, timeFormat.value))
watch([departureTimePart, timeFormat], () => {
departureTimeInput.value = formatTimeForFormat(departureTimePart.value, timeFormat.value)
})
watch([arrivalTimePart, timeFormat], () => {
arrivalTimeInput.value = formatTimeForFormat(arrivalTimePart.value, timeFormat.value)
})
function commitDepartureTimeInput() {
if (!departureTimeInput.value.trim()) {
departureTimePart.value = null
return
}
const parsed = parseTimeInput(departureTimeInput.value)
if (parsed !== null) {
departureTimePart.value = parsed
} else {
departureTimeInput.value = formatTimeForFormat(departureTimePart.value, timeFormat.value)
}
}
function commitArrivalTimeInput() {
if (!arrivalTimeInput.value.trim()) {
arrivalTimePart.value = null
return
}
const parsed = parseTimeInput(arrivalTimeInput.value)
if (parsed !== null) {
arrivalTimePart.value = parsed
} else {
arrivalTimeInput.value = formatTimeForFormat(arrivalTimePart.value, timeFormat.value)
}
}
</script>
<template>
@@ -249,27 +469,123 @@ const arrivalMax = computed(() => getArrivalBound(form, 3))
</v-col>
</v-row>
<!-- Departure + Arrival datetime -->
<!-- Departure date + time -->
<v-row>
<v-col cols="12" md="6">
<v-col cols="12" sm="6">
<v-text-field
v-model="form.departure_date"
label="Departure Date & Time"
type="datetime-local"
v-model="departureDateValue"
type="date"
label="Departure Date"
prepend-inner-icon="mdi-calendar"
:disabled="!lookupComplete"
:error-messages="submitForm.errors.departure_date"
/>
>
<template #append-inner>
<v-menu v-model="depDateMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }">
<v-icon
v-bind="menuProps"
icon="mdi-calendar-month"
style="cursor: pointer"
/>
</template>
<v-date-picker
v-model="departureDatePart"
@update:model-value="depDateMenu = false"
/>
</v-menu>
</template>
</v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-col cols="12" sm="6">
<v-menu v-model="depTimeMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }">
<v-text-field
v-bind="menuProps"
v-model="departureTimeInput"
label="Departure Time"
clearable
prepend-inner-icon="mdi-clock-outline"
:placeholder="timeFormat === '24hr' ? 'e.g. 1430' : 'e.g. 2:30 PM'"
:disabled="!lookupComplete"
@click:clear="clearDepartureTime"
@blur="commitDepartureTimeInput"
@keydown.enter="commitDepartureTimeInput"
/>
</template>
<v-card>
<v-time-picker
v-model="departureTimePart"
:format="timeFormat"
scrollable
/>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="depTimeMenu = false">OK</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</v-col>
</v-row>
<!-- Arrival date + time -->
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="form.arrival_date"
label="Arrival Date & Time"
type="datetime-local"
v-model="arrivalDateValue"
type="date"
label="Arrival Date"
prepend-inner-icon="mdi-calendar"
:disabled="!lookupComplete"
:error-messages="submitForm.errors.arrival_date"
:min="arrivalMin"
:max="arrivalMax"
/>
>
<template #append-inner>
<v-menu v-model="arrDateMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }">
<v-icon
v-bind="menuProps"
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>
</v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-menu v-model="arrTimeMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }">
<v-text-field
v-bind="menuProps"
v-model="arrivalTimeInput"
label="Arrival Time"
clearable
prepend-inner-icon="mdi-clock-outline"
:placeholder="timeFormat === '24hr' ? 'e.g. 1430' : 'e.g. 2:30 PM'"
:disabled="!lookupComplete"
@click:clear="clearArrivalTime"
@blur="commitArrivalTimeInput"
@keydown.enter="commitArrivalTimeInput"
/>
</template>
<v-card>
<v-time-picker
v-model="arrivalTimePart"
:format="timeFormat"
scrollable
/>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="arrTimeMenu = false">OK</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</v-col>
</v-row>
@@ -452,4 +768,8 @@ const arrivalMax = computed(() => getArrivalBound(form, 3))
</template>
<style scoped>
:deep(input[type="date"]::-webkit-calendar-picker-indicator) {
display: none;
-webkit-appearance: none;
}
</style>
+2 -1
View File
@@ -7,6 +7,7 @@ import {Head} from "@inertiajs/vue3";
defineProps<{
title: string;
missingLiveryCount: number;
missingAirlineCount: number;
}>()
</script>
@@ -15,7 +16,7 @@ defineProps<{
<MainLayout>
<Head :title="title" />
<div class="admin-container">
<AdminSidebar :missingLiveryCount="missingLiveryCount" />
<AdminSidebar :missingAirlineCount="missingAirlineCount" :missingLiveryCount="missingLiveryCount" />
<div class="admin-content">
<GlassBox class="admin-page" :title="title">
<slot />
@@ -3,6 +3,7 @@ import {Link} from "@inertiajs/vue3";
import {number} from "echarts";
defineProps<{
missingLiveryCount: number;
missingAirlineCount: number;
}>()
</script>
@@ -18,6 +19,11 @@ defineProps<{
Reconcile Missing Liveries
<v-chip size="x-small" color="error" class="ml-1">{{ missingLiveryCount }}</v-chip>
</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>
</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>
+10 -1
View File
@@ -12,6 +12,7 @@ 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";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
defineOptions({ layout: MainLayout })
@@ -26,6 +27,7 @@ const props = defineProps<{
airlines: Airline[]
continents: Continent[]
aircraft_families: Record<string, string[]>
majorAlliances: Alliance[]
achievementCount: number
canView: boolean
}>()
@@ -63,7 +65,7 @@ const unlocked = computed(() => {
<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
<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>
@@ -116,6 +118,13 @@ const unlocked = computed(() => {
:alliance="alliance"
:continents="continents"
:families="aircraft_families"
:major-alliances="majorAlliances"
/>
<FollowingSelectBox
redirect-route="profile.achievement"
:route-params="{ achievement: achievement.internal_name }"
placeholder="View the Progress of Someone You Follow"
/>
</div>
</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-col>
<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-col>
</v-row>
+3
View File
@@ -9,6 +9,7 @@ import MainLayout from "@/Layouts/MainLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import {useUpdateSetting} from "@/Composables/useUpdateSetting";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
const {updateSetting} = useUpdateSetting()
@@ -121,6 +122,8 @@ const filteredUnlockedCount = computed(() =>
/>
</div>
</Panel>
<FollowingSelectBox redirect-route="profile.achievements" placeholder="View The Achievements of Someone You Follow" />
</div>
</ProfileLayout>
</template>
+10
View File
@@ -56,6 +56,8 @@ const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedEngineTypes = ref<string[]>([])
const selectedEngineCounts = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([])
@@ -74,6 +76,8 @@ const activeFilterCount = computed(() =>
selectedSeatTypes.value.length +
selectedManufacturers.value.length +
selectedAircraftModels.value.length +
selectedEngineTypes.value.length +
selectedEngineCounts.value.length +
selectedAirportRegions.value.length +
selectedOtherUsers.value.length
)
@@ -93,6 +97,8 @@ function onFiltersChange(filters: {
seatTypes: number[]
manufacturers: string[]
aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[]
otherUsers: string[]
}) {
@@ -111,6 +117,8 @@ function onFiltersChange(filters: {
selectedSeatTypes.value = filters.seatTypes
selectedManufacturers.value = filters.manufacturers
selectedAircraftModels.value = filters.aircraftModels
selectedEngineTypes.value = filters.engineTypes
selectedEngineCounts.value = filters.engineCounts
selectedAirportRegions.value = filters.airportRegions
selectedOtherUsers.value = filters.otherUsers
}
@@ -141,6 +149,8 @@ function matchesFilters(f: Flight): boolean {
if (selectedSeatTypes.value.length && !selectedSeatTypes.value.includes(f.seat_type?.id ?? -1)) return false
if (selectedManufacturers.value.length && !selectedManufacturers.value.includes(f.aircraft?.manufacturer_code ?? '')) return false
if (selectedAircraftModels.value.length && !selectedAircraftModels.value.includes(f.aircraft?.id ?? -1)) return false
if (selectedEngineTypes.value.length && !selectedEngineTypes.value.includes(f.aircraft?.engine_type ?? '')) return false
if (selectedEngineCounts.value.length && !selectedEngineCounts.value.includes(f.aircraft?.engine_count ?? -1)) return false
if (selectedAirportRegions.value.length) {
const depRegion = f.departure_airport.region?.id
const arrRegion = f.arrival_airport.region?.id
+12 -1
View File
@@ -30,6 +30,8 @@ export interface UserSettings {
show_map_legend: boolean
hide_impossible_achievements: boolean
hide_map_filters: boolean
time_format: "ampm" | "24hr"
date_format: string
}
@@ -99,6 +101,7 @@ export type SharedProps = import('@inertiajs/core').PageProps & {
roles: string[];
permissions: string[];
apiToken: string | null;
following: string[];
},
flash: {
success?: string;
@@ -303,7 +306,7 @@ export interface Flight {
airline: Airline | null
aircraft: Aircraft | null
seat_type: SeatType | null
crew_type: CrewType | null
crew_type?: CrewType | null
flight_reason: FlightReason | null
flight_class: FlightClass | null
duration_display: string
@@ -326,6 +329,14 @@ export interface MissingLivery {
clipboard_text: string;
}
export interface MissingAirline{
code: string;
flights: {
flight_number: string;
link: string;
}[]
}
declare module '@inertiajs/vue3' {
interface PageProps extends SharedProps {}
}
+1
View File
@@ -2,3 +2,4 @@
/// <reference types="vite-plugin-pwa/client" />
declare module '*.css'
declare const __APP_VERSION__: string
+1
View File
@@ -9,6 +9,7 @@
<link rel="icon" type="image/x-icon" href="{{ asset('img/favicons/favicon.png') }}">
<!-- Fonts -->
<meta name="description" content="FlightsGoneBy is the ultimate flight diary — log every flight, earn achievements, visualise your travels on an interactive map, and share your stats with friends.">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Apple Touch Icons -->
+2
View File
@@ -40,6 +40,8 @@ use Inertia\Inertia;
Route::get('/', [AdminController::class, 'dashboard'])->name('dashboard');
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::get('/missing-airlines', [AdminController::class, 'reconcileMissingAirlines'])->name('reconcile-missing-airlines')->middleware('permission:reconcile_missing_liveries');
});
+14
View File
@@ -4,8 +4,22 @@ import vue from '@vitejs/plugin-vue';
import path from 'path';
import vuetify from "vite-plugin-vuetify";
import { VitePWA } from 'vite-plugin-pwa';
import { execSync } from 'child_process'
function getAppVersion(): string {
try {
// Prefer a tag if one points at HEAD, else fall back to short hash
return execSync('git describe --tags --always --dirty').toString().trim()
} catch {
return 'Unknown'
}
}
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION ?? 'Dev'),
},
plugins: [
laravel({
input: 'resources/js/app.ts',