Author SHA1 Message Date
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
30 changed files with 1021 additions and 259 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!",
@@ -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');
+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.', 'AI Generated Content' => 'Airline tail logos and liveries are AI generated with human cleanup. If you would rather not see any AI, then our blank aircraft templates are human created.',
'Account & Privacy' => 'Everything to do with your account.', 'Account & Privacy' => 'Everything to do with your account.',
'Notifications' => 'What you want to hear about (or not!)', 'Notifications' => 'What you want to hear about (or not!)',
'Localization' => 'Units of measurement, date and time preferences.',
]; ];
} }
public static function schema(): array public static function schema(): array
{ {
return [ return [
[
'key' => 'distance_unit',
'type' => 'select',
'label' => 'Distance Units',
'category' => 'FlightsGoneBy Settings',
'default' => 'km',
'options' => [
['value' => 'km', 'label' => 'Kilometres (km)'],
['value' => 'mi', 'label' => 'Miles (mi)'],
['value' => 'nm', 'label' => 'Nautical miles (nm)'],
],
],
[ [
'category' => 'Account & Privacy', 'category' => 'Account & Privacy',
'key' => 'profile_privacy', 'key' => 'profile_privacy',
@@ -42,6 +31,42 @@ class SettingsRegistry
['value' => 'private', 'label' => 'Private Profile Viewable Only By You and Your Approved Followers'], ['value' => 'private', 'label' => 'Private Profile Viewable Only By You and Your Approved Followers'],
] ]
], ],
[
'key' => 'distance_unit',
'type' => 'select',
'label' => 'Distance Units',
'category' => 'Localization',
'default' => 'km',
'options' => [
['value' => 'km', 'label' => 'Kilometres (km)'],
['value' => 'mi', 'label' => 'Miles (mi)'],
['value' => 'nm', 'label' => 'Nautical miles (nm)'],
],
],
[
'key' => 'time_format',
'type' => 'select',
'label' => 'Time Format',
'category' => 'Localization',
'default' => 'ampm',
'options' => [
['value' => 'ampm', 'label' => 'AM/PM'],
['value' => '24hr', 'label' => '24-Hour'],
],
],
[
'key' => 'date_format',
'type' => 'select',
'label' => 'Date Format',
'category' => 'Localization',
'default' => 'j M Y',
'options' => [
['value' => 'j M Y', 'label' => '20 Feb 2015'],
['value' => 'Y-m-d', 'label' => '2015-02-20'],
['value' => 'd/m/Y', 'label' => '20/02/2015'],
['value' => 'm/d/Y', 'label' => '02/20/2015'],
],
],
[ [
'key' => 'default_login_page', 'key' => 'default_login_page',
'type' => 'select', 'type' => 'select',
@@ -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
{
//
}
};
+1 -1
View File
@@ -8,7 +8,7 @@
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5", "apexcharts": "^5.10.5",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"echarts": "^6.0.0", "echarts": "^6.1.0",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"maplibre-gl": "^5.22.0", "maplibre-gl": "^5.22.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
+1 -1
View File
@@ -41,7 +41,7 @@
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5", "apexcharts": "^5.10.5",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"echarts": "^6.0.0", "echarts": "^6.1.0",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"maplibre-gl": "^5.22.0", "maplibre-gl": "^5.22.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
@@ -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>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue";
import {FlightStats} from "@/Composables/useFlightStats";
const props = defineProps<{
flightStats: FlightStats
}>()
</script>
<template>
<DonutChart
title="Alliances"
:height="280"
:labels="flightStats.alliances.value.labels"
:series="flightStats.alliances.value.series"
total-label="Alliances"
center-mode="count"
/>
</template>
<style scoped>
</style>
@@ -1,80 +1,283 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { ChartType } from '@/Types/types' import { use } from 'echarts/core'
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue" import { CanvasRenderer } from 'echarts/renderers'
import VueApexCharts from "vue3-apexcharts" import { PieChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent])
const chartRef = ref<InstanceType<typeof VChart>>()
onMounted(() => {
// vue-echarts' autoresize can measure the container before the page's
// own layout (flex/grid) has settled, leaving the chart off-center until
// something else triggers a resize. Force one right after mount.
nextTick(() => chartRef.value?.resize())
})
type SeriesEntry = { name: string; data: number[] }
interface RingConfig {
name: string
series: number[]
colors: string[]
}
const props = defineProps<{ const props = defineProps<{
title: string title: string
height: number | string height: number | string
series: number[]
labels: string[] labels: string[]
// Either a flat array (single ring, e.g. Flight Types) or an array of
// named series (multi ring, e.g. Continents: [{name:'Flights',data},{name:'Upcoming',data}])
series: number[] | SeriesEntry[]
total?: number total?: number
options?: object totalLabel?: string
// 'sum' (default): center shows the total of all values (e.g. 308 flights).
// 'count': center shows how many categories have a non-zero value (e.g. 6
// continents) — for charts where summing the slices double-counts things,
// like a flight touching two continents.
centerMode?: 'sum' | 'count'
}>() }>()
const chartOptions = computed(() => ({ // Same color for the same label across every ring (matches the legend),
chart: { // distinguished visually by which ring they're in rather than by shade.
type: 'donut', const PALETTE = ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4', 'pink']
background: 'transparent',
fontFamily: 'inherit',
animations: { enabled: false },
},
theme: { mode: 'dark' },
labels: props.labels,
colors: ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4', 'pink'],
dataLabels: {
enabled: true,
formatter: (val: number) => `${Math.round(val)}%`,
style: { fontSize: '12px', fontWeight: 400 },
dropShadow: { enabled: false },
},
plotOptions: {
pie: {
donut: {
size: '60%',
labels: {
show: true,
total: {
show: true,
label: 'Total',
fontSize: '13px',
color: '#556677',
formatter: () => (props.total ?? props.series.reduce((a, b) => a + b, 0)).toString(),
},
value: {
fontSize: '22px',
fontWeight: 500,
color: '#e0e6f0',
},
},
},
},
},
legend: {
position: 'bottom',
labels: { colors: '#778899' },
markers: { width: 8, height: 8, radius: 2 },
itemMargin: { horizontal: 12, vertical: 4 },
},
stroke: { width: 0 },
tooltip: {
theme: 'dark',
y: { formatter: (val: number) => `${val} flights` },
},
}))
// Build rings from `series`, dropping any ring that's entirely zero
// (e.g. a continent with no upcoming flights at all -> no "Upcoming" ring).
const normalizedRings = computed<RingConfig[]>(() => {
const raw = props.series
if (!raw || !raw.length) return []
if (typeof raw[0] === 'number') {
return [{ name: props.title, series: raw as number[], colors: PALETTE }]
}
return (raw as SeriesEntry[])
.map((entry) => ({ name: entry.name, series: entry.data, colors: PALETTE }))
.filter((ring) => ring.series.some((v) => v > 0))
})
const isMulti = computed(() => normalizedRings.value.length > 1)
const mode = ref<'all' | string>('all')
const modeOptions = computed(() => [
{ key: 'all', label: normalizedRings.value.length === 2 ? 'Both' : 'All' },
...normalizedRings.value.map((r) => ({ key: r.name, label: `${r.name} only` })),
])
// Skip zero-value slices entirely, rather than drawing an invisible sliver with a stray label/border.
function toSeriesData(ring: RingConfig) {
return ring.series
.map((value, i) => ({
name: props.labels[i] ?? `Item ${i + 1}`,
value,
itemStyle: { color: ring.colors[i % ring.colors.length] },
}))
.filter((d) => d.value > 0)
}
function ringCount(ring: RingConfig) {
return ring.series.filter((v) => v > 0).length
}
function ringSum(ring: RingConfig) {
return ring.series.reduce((a, b) => a + b, 0)
}
const centerLabel = computed(() => {
const aggregate = props.centerMode === 'count' ? ringCount : ringSum
if (mode.value !== 'all') {
const ring = normalizedRings.value.find((r) => r.name === mode.value)!
return { title: ring.name, value: aggregate(ring) }
}
if (props.centerMode === 'count') {
// Distinct categories with a non-zero value in ANY ring (not summed
// across rings, since a continent visited on both legs is still one continent).
const present = new Set<string>()
normalizedRings.value.forEach((ring) => {
ring.series.forEach((value, i) => {
if (value > 0) present.add(props.labels[i] ?? `Item ${i + 1}`)
})
})
return { title: props.totalLabel ?? 'Total', value: present.size }
}
return {
title: props.totalLabel ?? 'Total',
value: props.total ?? normalizedRings.value.reduce((sum, r) => sum + ringSum(r), 0),
}
})
const activeRings = computed(() =>
mode.value === 'all' ? normalizedRings.value : normalizedRings.value.filter((r) => r.name === mode.value)
)
// Same radius bands as the original working sample:
// - isolated (single ring, or one toggled on its own): 55%80%
// - nested pair: outer 68%88%, inner 32%62% (leaves a roomy center hole)
// - 3+ rings: evenly spaced bands, falling back to a generous hole
function ringRadius(i: number, n: number, isolated: boolean): [number, number] {
if (isolated) return [55, 80]
if (n === 2) return i === 0 ? [68, 88] : [42, 62]
const edge = 88
const hole = 30
const gap = 6
const bandWidth = (edge - hole - gap * (n - 1)) / n
const outer = edge - i * (bandWidth + gap)
const inner = outer - bandWidth
return [inner, outer]
}
const numericHeight = computed(() => (typeof props.height === 'number' ? props.height : parseInt(String(props.height), 10) || 280))
interface SliceLabel {
key: string
x: number
y: number
text: string
}
// Computed independently of ECharts' own label placement (see chartOption below
// for why): for each visible slice, find its mid-angle and the ring's
// mid-radius, then convert to a pixel offset from the chart's center using
// plain trig. ECharts pies start at 90° (12 o'clock) and sweep clockwise,
// which in standard (counter-clockwise-positive) math angle terms means the
// angle decreases as cumulative value increases.
const sliceLabels = computed<SliceLabel[]>(() => {
const n = normalizedRings.value.length
const isolated = mode.value !== 'all' || n === 1
const chartRadiusPx = numericHeight.value / 2
const labels: SliceLabel[] = []
activeRings.value.forEach((ring) => {
const i = normalizedRings.value.findIndex((r) => r.name === ring.name)
const [innerPct, outerPct] = ringRadius(i, n, isolated)
const midRadiusPx = ((innerPct + outerPct) / 2 / 100) * chartRadiusPx
const total = ring.series.reduce((a, b) => a + b, 0)
if (!total) return
let cumulative = 0
ring.series.forEach((value, idx) => {
const percent = (value / total) * 100
const midFraction = (cumulative + value / 2) / total
cumulative += value
if (value <= 0 || percent < 5) return
const angleDeg = 90 - midFraction * 360
const angleRad = (angleDeg * Math.PI) / 180
labels.push({
key: `${ring.name}-${props.labels[idx] ?? idx}`,
x: midRadiusPx * Math.cos(angleRad),
y: -midRadiusPx * Math.sin(angleRad),
text: `${Math.round(percent)}%`,
})
})
})
return labels
})
const chartOption = computed(() => {
const n = normalizedRings.value.length
const isolated = mode.value !== 'all' || n === 1
const series = activeRings.value.map((ring) => {
const i = normalizedRings.value.findIndex((r) => r.name === ring.name)
const [innerPct, outerPct] = ringRadius(i, n, isolated)
return {
name: ring.name,
type: 'pie',
radius: [`${innerPct}%`, `${outerPct}%`],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderColor: 'transparent', borderWidth: 0 },
// ECharts' own label placement (both 'inside' and 'outside' + negative
// distance) proved unreliable here, so labels are rendered separately
// as plain positioned HTML — see `sliceLabels` below.
label: { show: false },
labelLine: { show: false },
data: toSeriesData(ring),
}
})
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: '#0f1b33',
borderColor: '#2a3a5c',
textStyle: { color: '#e0e6f0', fontSize: 12 },
formatter: (p: any) =>
`<b>${p.seriesName} · ${p.name}</b><br/>${p.value} flights (${Math.round(p.percent)}%)`,
},
series,
}
})
const legendItems = computed(() => {
const first = normalizedRings.value[0]
if (!first) return []
return props.labels.map((label, i) => ({ label, color: first.colors[i % first.colors.length] }))
})
</script> </script>
<template> <template>
<div class="chart-wrap"> <div class="chart-wrap">
<div class="chart-title">{{ title }}</div> <div class="chart-title">{{ title }}</div>
<VueApexCharts
type="donut" <template v-if="isMulti">
:height="height" <div class="toggle-row">
:options="options ?? chartOptions" <button
:series="series" v-for="opt in modeOptions"
/> :key="opt.key"
class="toggle-btn"
:class="{ active: mode === opt.key }"
@click="mode = opt.key"
>
{{ opt.label }}
</button>
</div>
<div v-if="mode === 'all'" class="ring-labels">
<span v-for="(r, i) in normalizedRings" :key="r.name">
&#9899; {{ i === 0 ? 'Outer' : i === normalizedRings.length - 1 ? 'Inner' : `Ring ${i + 1}` }}
Ring = {{ r.name }}
</span>
</div>
</template>
<div class="donut-container" :style="{ height: typeof height === 'number' ? `${height}px` : height }">
<VChart ref="chartRef" class="chart" :option="chartOption" autoresize />
<div class="center-label">
<div class="center-label-title">{{ centerLabel.title }}</div>
<div class="center-label-value">{{ centerLabel.value }}</div>
</div>
<div
v-for="lbl in sliceLabels"
:key="lbl.key"
class="slice-label"
:style="{ transform: `translate(calc(-50% + ${lbl.x}px), calc(-50% + ${lbl.y}px))` }"
>
{{ lbl.text }}
</div>
</div>
<div v-if="legendItems.length" class="legend">
<div v-for="item in legendItems" :key="item.label" class="legend-item">
<span class="dot" :style="{ background: item.color }"></span>
<span>{{ item.label }}</span>
</div>
</div>
</div> </div>
</template> </template>
@@ -82,6 +285,7 @@ const chartOptions = computed(() => ({
.chart-wrap { .chart-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
gap: 4px; gap: 4px;
} }
@@ -93,11 +297,99 @@ const chartOptions = computed(() => ({
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.chart-hidden { .donut-container {
visibility: hidden; position: relative;
width: 100%;
} }
:deep(svg) { .chart {
outline: none; width: 100%;
height: 100%;
}
.center-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
}
.center-label-title {
font-size: 13px;
color: #556677;
}
.center-label-value {
font-size: 22px;
font-weight: 500;
color: #e0e6f0;
}
.slice-label {
position: absolute;
top: 50%;
left: 50%;
color: #fff;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
}
.toggle-row {
display: flex;
justify-content: center;
gap: 8px;
margin: 4px 0 4px;
}
.toggle-btn {
background: transparent;
border: 1px solid #2a3a5c;
color: #778899;
border-radius: 20px;
padding: 5px 12px;
font-size: 11px;
cursor: pointer;
}
.toggle-btn.active {
background: #1e3a6a;
color: #e0e6f0;
border-color: #1e3a6a;
}
.ring-labels {
display: flex;
justify-content: center;
gap: 20px;
font-size: 11px;
color: #556677;
margin-bottom: 4px;
}
.legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px;
margin-top: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #778899;
}
.dot {
width: 8px;
height: 8px;
border-radius: 2px;
display: inline-block;
} }
</style> </style>
@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue"; import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue";
import {FlightStats} from "@/Composables/useFlightStats"; import { FlightStats } from "@/Composables/useFlightStats";
const props = defineProps<{ const props = defineProps<{
flightStats: FlightStats flightStats: FlightStats
}>() }>()
</script> </script>
<template> <template>
<DonutChart <DonutChart
title="Continents" title="Continents"
:height="280" :height="280"
:labels="flightStats.continents.value.labels" :labels="flightStats.continents.value.labels"
:series="flightStats.continents.value.series" :series="flightStats.continents.value.series"
total-label="Continents"
center-mode="count"
/> />
</template> </template>
<style scoped>
<style scoped>
</style> </style>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280" :height="280"
:labels="flightStats.classes.value.labels" :labels="flightStats.classes.value.labels"
:series="flightStats.classes.value.series" :series="flightStats.classes.value.series"
total-label="Flight Classes"
center-mode="count"
/> />
</template> </template>
<style scoped> <style scoped>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280" :height="280"
:labels="flightStats.reasons.value.labels" :labels="flightStats.reasons.value.labels"
:series="flightStats.reasons.value.series" :series="flightStats.reasons.value.series"
total-label="Reasons"
center-mode="count"
/> />
</template> </template>
<style scoped> <style scoped>
@@ -7,8 +7,10 @@ const props = defineProps<{
</script> </script>
<template> <template>
<DonutChart <DonutChart
title="Flight Types" title="Flight Scopes"
:height="280" :height="280"
center-mode="count"
total-label="Flight Scopes"
:labels="flightStats.flightTypes.value.labels" :labels="flightStats.flightTypes.value.labels"
:series="flightStats.flightTypes.value.series" :series="flightStats.flightTypes.value.series"
/> />
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280" :height="280"
:labels="flightStats.seatTypes.value.labels" :labels="flightStats.seatTypes.value.labels"
:series="flightStats.seatTypes.value.series" :series="flightStats.seatTypes.value.series"
center-mode="count"
total-label="Seat Types"
/> />
</template> </template>
<style scoped> <style scoped>
@@ -52,7 +52,7 @@ const chartEvents = computed(() => ({
<InlineBadge v-if="index !== null" variant="generic">{{ aircraft[index].designator }}</InlineBadge> <InlineBadge v-if="index !== null" variant="generic">{{ aircraft[index].designator }}</InlineBadge>
</div> </div>
<div class="ct-row"> <div class="ct-row">
<span class="ct-label">Flights</span> <span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? aircraft[index].past : '' }}</span> <span class="ct-val">{{ index !== null ? aircraft[index].past : '' }}</span>
</div> </div>
<div v-if="index !== null && aircraft[index].upcoming" class="ct-row"> <div v-if="index !== null && aircraft[index].upcoming" class="ct-row">
@@ -52,7 +52,7 @@ const chartEvents = computed(() => ({
{{ index !== null ? airlines[index].name : '' }} {{ index !== null ? airlines[index].name : '' }}
</div> </div>
<div class="ct-row"> <div class="ct-row">
<span class="ct-label">Flights</span> <span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? airlines[index].past : '' }}</span> <span class="ct-val">{{ index !== null ? airlines[index].past : '' }}</span>
</div> </div>
<div v-if="index !== null && airlines[index].upcoming" class="ct-row"> <div v-if="index !== null && airlines[index].upcoming" class="ct-row">
@@ -37,7 +37,7 @@ const series = computed(() => props.flightStats.topRoutes.value.series)
<ChartTooltip :visible="visible" :x="x" :y="y"> <ChartTooltip :visible="visible" :x="x" :y="y">
<div class="ct-name">{{ index !== null ? routes[index].label : '' }}</div> <div class="ct-name">{{ index !== null ? routes[index].label : '' }}</div>
<div class="ct-row"> <div class="ct-row">
<span class="ct-label">Flights</span> <span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? routes[index].past : '' }}</span> <span class="ct-val">{{ index !== null ? routes[index].past : '' }}</span>
</div> </div>
<div v-if="index !== null && routes[index].upcoming" class="ct-row"> <div v-if="index !== null && routes[index].upcoming" class="ct-row">
@@ -98,12 +98,12 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
<td v-if="showColumn('departure_time')" class="v-data-table__td"> <td v-if="showColumn('departure_time')" class="v-data-table__td">
<span class="time-cell"> <span class="time-cell">
<Mono small>{{ flight.departure_time_display }}</Mono> <Mono small>{{flight.departure_time_display}}</Mono>
</span> </span>
</td> </td>
<td v-if="showColumn('arrival_time')" class="v-data-table__td arrival-time-cell"> <td v-if="showColumn('arrival_time')" class="v-data-table__td arrival-time-cell">
<Mono small>{{ flight.arrival_time_display }}</Mono> <Mono small>{{flight.arrival_time_display}}</Mono>
<DayDifference :value="flight.arrival_day_difference" /> <DayDifference :value="flight.arrival_day_difference" />
</td> </td>
@@ -15,6 +15,7 @@ import TopRoutesChart from '@/Components/FlightsGoneBy/Charts/TopRoutesChart.vue
import FlightTypeChart from '@/Components/FlightsGoneBy/Charts/FlightTypeChart.vue' import FlightTypeChart from '@/Components/FlightsGoneBy/Charts/FlightTypeChart.vue'
import {FlightStats} from "@/Composables/useFlightStats"; import {FlightStats} from "@/Composables/useFlightStats";
import TopAircraftChart from "@/Components/FlightsGoneBy/Charts/TopAircraftChart.vue"; import TopAircraftChart from "@/Components/FlightsGoneBy/Charts/TopAircraftChart.vue";
import AllianceChart from "@/Components/FlightsGoneBy/Charts/AllianceChart.vue";
defineProps<{ defineProps<{
flightStats: FlightStats flightStats: FlightStats
@@ -56,11 +57,14 @@ defineProps<{
<!-- Airlines + Airports --> <!-- Airlines + Airports -->
<div class="flight-charts glass charts-row"> <div class="flight-charts glass charts-row">
<TopAirlinesChart :flight-stats="flightStats" /> <TopAirlinesChart :flight-stats="flightStats" />
<TopAirportsChart :flight-stats="flightStats" /> <div class="charts-stack">
<AllianceChart :flight-stats="flightStats" />
</div>
</div> </div>
<!-- Routes + International vs Domestic --> <!-- Routes + International vs Domestic -->
<div class="flight-charts glass charts-row"> <div class="flight-charts glass charts-row">
<TopAirportsChart :flight-stats="flightStats" />
<TopRoutesChart :flight-stats="flightStats" /> <TopRoutesChart :flight-stats="flightStats" />
<TopAircraftChart :flight-stats="flightStats" /> <TopAircraftChart :flight-stats="flightStats" />
</div> </div>
@@ -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])
@@ -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>
+82 -31
View File
@@ -89,32 +89,51 @@ export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
// ── Grouping helpers ────────────────────────────────────────────────────────── // ── Grouping helpers ──────────────────────────────────────────────────────────
function groupByName(flights: Flight[], accessor: (f: Flight) => string) { function groupByNameSplit(flights: Flight[], upcomingFlights: Flight[], accessor: (f: Flight) => string) {
const countOne = (list: Flight[]) => {
const counts = new Map<string, number>() const counts = new Map<string, number>()
flights.forEach(f => { list.forEach(f => {
const key = accessor(f) ?? 'Unknown' const key = accessor(f) ?? 'Unknown'
counts.set(key, (counts.get(key) ?? 0) + 1) counts.set(key, (counts.get(key) ?? 0) + 1)
}) })
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]) return counts
}
const past = countOne(flights)
const upcoming = countOne(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
const sorted = [...allNames]
.map(name => ({
name,
past: past.get(name) ?? 0,
upcoming: upcoming.get(name) ?? 0,
}))
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
return { return {
labels: sorted.map(([name]) => name), labels: sorted.map(s => s.name),
series: sorted.map(([, count]) => count), series: [
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
} }
} }
export function getFlightReasons(flights: Flight[]) { export function getFlightReasons(flights: Flight[], upcomingFlights: Flight[]) {
const result = groupByName(flights, f => f.flight_reason?.name ?? 'Unknown') return groupByNameSplit(flights, upcomingFlights, f => f.flight_reason?.name ?? 'Unknown')
return result
} }
export function getFlightClasses(flights: Flight[]) { export function getFlightClasses(flights: Flight[], upcomingFlights: Flight[]) {
const result = groupByName(flights, f => f.flight_class?.name ?? 'Unknown') return groupByNameSplit(flights, upcomingFlights, f => f.flight_class?.name ?? 'Unknown')
return result
} }
export function getSeatTypes(flights: Flight[]) { export function getSeatTypes(flights: Flight[], upcomingFlights: Flight[]) {
const result = groupByName(flights, f => f.seat_type?.name ?? 'Unknown') return groupByNameSplit(flights, upcomingFlights, f => f.seat_type?.name ?? 'Unknown')
return result }
export function getAlliances(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.airline?.alliance?.name ?? 'No Alliance')
} }
// ── Countries ───────────────────────────────────────────────────────────────── // ── Countries ─────────────────────────────────────────────────────────────────
@@ -156,7 +175,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
countries: sorted, countries: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Departed', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -165,7 +184,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
// ── Continents ──────────────────────────────────────────────────────────────── // ── Continents ────────────────────────────────────────────────────────────────
export function getContinents(flights: Flight[]) { function countContinents(flights: Flight[]) {
const counts = new Map<string, number>() const counts = new Map<string, number>()
flights.forEach(f => { flights.forEach(f => {
const continents = new Set<string>() const continents = new Set<string>()
@@ -175,10 +194,28 @@ export function getContinents(flights: Flight[]) {
if (arr) continents.add(arr) if (arr) continents.add(arr)
continents.forEach(c => counts.set(c, (counts.get(c) ?? 0) + 1)) continents.forEach(c => counts.set(c, (counts.get(c) ?? 0) + 1))
}) })
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]) return counts
}
export function getContinents(flights: Flight[], upcomingFlights: Flight[]) {
const past = countContinents(flights)
const upcoming = countContinents(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
const sorted = [...allNames]
.map(name => ({
name,
past: past.get(name) ?? 0,
upcoming: upcoming.get(name) ?? 0,
}))
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
const result = { const result = {
labels: sorted.map(([name]) => name), labels: sorted.map(s => s.name),
series: sorted.map(([, count]) => count), series: [
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
} }
return result return result
} }
@@ -215,7 +252,7 @@ export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
airlines: sorted, airlines: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -338,7 +375,7 @@ export function getTopAircraft(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
aircraft: sorted, aircraft: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -391,7 +428,7 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
routes: sorted, routes: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -400,7 +437,7 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
// ── Flight types ────────────────────────────────────────────────────────────── // ── Flight types ──────────────────────────────────────────────────────────────
export function getFlightTypes(flights: Flight[]) { function countFlightTypes(flights: Flight[]) {
const counts = { International: 0, Domestic: 0 } const counts = { International: 0, Domestic: 0 }
flights.forEach(f => { flights.forEach(f => {
const dep = f.departure_airport.region?.country?.id const dep = f.departure_airport.region?.country?.id
@@ -409,10 +446,22 @@ export function getFlightTypes(flights: Flight[]) {
dep === arr ? counts.Domestic++ : counts.International++ dep === arr ? counts.Domestic++ : counts.International++
} }
}) })
const sorted = Object.entries(counts).filter(([, count]) => count > 0) return counts
}
export function getFlightTypes(flights: Flight[], upcomingFlights: Flight[]) {
const past = countFlightTypes(flights)
const upcoming = countFlightTypes(upcomingFlights)
const names = ['International', 'Domestic'] as const
const relevant = names.filter(name => past[name] > 0 || upcoming[name] > 0)
const result = { const result = {
labels: sorted.map(([name]) => name), labels: relevant,
series: sorted.map(([, count]) => count), series: [
{ name: 'Flown', data: relevant.map(name => past[name]) },
{ name: 'Upcoming', data: relevant.map(name => upcoming[name]) },
],
} }
return result return result
} }
@@ -443,16 +492,17 @@ export function useFlightStats(flights: Ref<Flight[]>) {
const perYear = computed(() => getFlightsPerYear(pastFlights.value, upcomingFlights.value)) const perYear = computed(() => getFlightsPerYear(pastFlights.value, upcomingFlights.value))
const perMonth = computed(() => getFlightsPerMonth(pastFlights.value, upcomingFlights.value)) const perMonth = computed(() => getFlightsPerMonth(pastFlights.value, upcomingFlights.value))
const perDay = computed(() => getFlightsPerDay(pastFlights.value, upcomingFlights.value)) const perDay = computed(() => getFlightsPerDay(pastFlights.value, upcomingFlights.value))
const reasons = computed(() => getFlightReasons(allFlights.value)) const reasons = computed(() => getFlightReasons(pastFlights.value, upcomingFlights.value))
const classes = computed(() => getFlightClasses(allFlights.value)) const classes = computed(() => getFlightClasses(pastFlights.value, upcomingFlights.value))
const seatTypes = computed(() => getSeatTypes(allFlights.value)) const seatTypes = computed(() => getSeatTypes(pastFlights.value, upcomingFlights.value))
const alliances = computed(() => getAlliances(pastFlights.value, upcomingFlights.value))
const countries = computed(() => getCountries(pastFlights.value, upcomingFlights.value)) const countries = computed(() => getCountries(pastFlights.value, upcomingFlights.value))
const continents = computed(() => getContinents(allFlights.value)) const continents = computed(() => getContinents(pastFlights.value, upcomingFlights.value))
const topAirlines = computed(() => getTopAirlines(pastFlights.value, upcomingFlights.value)) const topAirlines = computed(() => getTopAirlines(pastFlights.value, upcomingFlights.value))
const topAirports = computed(() => getTopAirports(pastFlights.value, upcomingFlights.value)) const topAirports = computed(() => getTopAirports(pastFlights.value, upcomingFlights.value))
const topAircraft = computed(() => getTopAircraft(pastFlights.value, upcomingFlights.value)) const topAircraft = computed(() => getTopAircraft(pastFlights.value, upcomingFlights.value))
const topRoutes = computed(() => getTopRoutes(pastFlights.value, upcomingFlights.value)) const topRoutes = computed(() => getTopRoutes(pastFlights.value, upcomingFlights.value))
const flightTypes = computed(() => getFlightTypes(allFlights.value)) const flightTypes = computed(() => getFlightTypes(pastFlights.value, upcomingFlights.value))
return { return {
pastFlights, pastFlights,
@@ -463,6 +513,7 @@ export function useFlightStats(flights: Ref<Flight[]>) {
reasons, reasons,
classes, classes,
seatTypes, seatTypes,
alliances,
countries, countries,
continents, continents,
flightTypes, flightTypes,
+164 -42
View File
@@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import MainLayout from '@/Layouts/MainLayout.vue' import MainLayout from '@/Layouts/MainLayout.vue'
import GlassBox from '@/Components/FlightsGoneBy/GlassBox.vue' import GlassBox from '@/Components/FlightsGoneBy/GlassBox.vue'
import { Head, useForm } from '@inertiajs/vue3' import {Head, useForm, usePage} from '@inertiajs/vue3'
import AirlineSearchBox from '@/Components/FlightsGoneBy/AirlineSearchBox.vue' import AirlineSearchBox from '@/Components/FlightsGoneBy/AirlineSearchBox.vue'
import AircraftSearchBox from '@/Components/FlightsGoneBy/AircraftSearchBox.vue' import AircraftSearchBox from '@/Components/FlightsGoneBy/AircraftSearchBox.vue'
import AirportSearchBox from '@/Components/FlightsGoneBy/AirportSearchBox.vue' import AirportSearchBox from '@/Components/FlightsGoneBy/AirportSearchBox.vue'
import type {SeatType, FlightReason, FlightClass, CrewType} from '@/Types/types' import type {SeatType, FlightReason, FlightClass, CrewType, SharedProps} from '@/Types/types'
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { VTimePicker, VDatePicker } from 'vuetify/components' import { VTimePicker, VDatePicker } from 'vuetify/components'
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
const page = usePage<SharedProps>().props
const props = defineProps<{ const props = defineProps<{
flight?: { flight?: {
@@ -39,6 +39,11 @@ const props = defineProps<{
const isEdit = !!props.flight const isEdit = !!props.flight
// ── User format preferences ─────────────────────────────────────────────────
const timeFormat = computed(() => page.auth?.user?.resolved_settings?.time_format ?? 'ampm')
const pad = (n: number) => String(n).padStart(2, '0')
// ── Flight number lookup ────────────────────────────────────────────────────── // ── Flight number lookup ──────────────────────────────────────────────────────
const flightNumber = ref(props.flight?.flight_number ?? '') const flightNumber = ref(props.flight?.flight_number ?? '')
@@ -209,8 +214,6 @@ const aircraftOptionsData = ref<{ value: number; title: string }[]>(props.flight
// on. These refs + watchers just give the template two separate pickers while // on. These refs + watchers just give the template two separate pickers while
// keeping those combined strings in sync — no server-side changes needed. // keeping those combined strings in sync — no server-side changes needed.
const pad = (n: number) => String(n).padStart(2, '0')
function parseDateTime(dt: string | null | undefined) { function parseDateTime(dt: string | null | undefined) {
if (!dt) return { date: null as Date | null, time: null as string | null } if (!dt) return { date: null as Date | null, time: null as string | null }
const d = new Date(dt) const d = new Date(dt)
@@ -227,23 +230,6 @@ function combineDateTime(date: Date | null, time: string | null): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${(hh ?? '00').padStart(2, '0')}:${(mm ?? '00').padStart(2, '0')}` return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${(hh ?? '00').padStart(2, '0')}:${(mm ?? '00').padStart(2, '0')}`
} }
const formatDateDisplay = (d: Date | null) =>
d ? d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : ''
// Time is stored internally as a 24hr "HH:mm" string (VTimePicker's model
// value is always 24hr regardless of the `format` display prop). This just
// formats it for display in the read-only text fields.
function formatTimeDisplay(t: string | null): string {
if (!t) return ''
const [hStr, mStr] = t.split(':')
let h = parseInt(hStr, 10)
if (isNaN(h)) return ''
const ampm = h >= 12 ? 'PM' : 'AM'
h = h % 12
if (h === 0) h = 12
return `${h}:${mStr} ${ampm}`
}
function clearDepartureDate() { departureDatePart.value = null } function clearDepartureDate() { departureDatePart.value = null }
function clearDepartureTime() { departureTimePart.value = null } function clearDepartureTime() { departureTimePart.value = null }
function clearArrivalDate() { arrivalDatePart.value = null } function clearArrivalDate() { arrivalDatePart.value = null }
@@ -324,6 +310,124 @@ const arrivalMax = computed(() => getArrivalBound(form, 3))
const arrivalMinDate = computed(() => arrivalMin.value ? new Date(arrivalMin.value) : undefined) const arrivalMinDate = computed(() => arrivalMin.value ? new Date(arrivalMin.value) : undefined)
const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.value) : undefined) const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.value) : undefined)
// ── Native date inputs ───────────────────────────────────────────────────────
// departureDatePart / arrivalDatePart stay the source of truth as Date
// objects. 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> </script>
<template> <template>
@@ -368,18 +472,21 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
<!-- Departure date + time --> <!-- Departure date + time -->
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="depDateMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }">
<v-text-field <v-text-field
v-bind="menuProps" v-model="departureDateValue"
:model-value="formatDateDisplay(departureDatePart)" type="date"
label="Departure Date" label="Departure Date"
readonly
clearable
prepend-inner-icon="mdi-calendar" prepend-inner-icon="mdi-calendar"
:disabled="!lookupComplete" :disabled="!lookupComplete"
:error-messages="submitForm.errors.departure_date" :error-messages="submitForm.errors.departure_date"
@click:clear="clearDepartureDate" >
<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> </template>
<v-date-picker <v-date-picker
@@ -387,25 +494,29 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
@update:model-value="depDateMenu = false" @update:model-value="depDateMenu = false"
/> />
</v-menu> </v-menu>
</template>
</v-text-field>
</v-col> </v-col>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="depTimeMenu" :close-on-content-click="false"> <v-menu v-model="depTimeMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }"> <template #activator="{ props: menuProps }">
<v-text-field <v-text-field
v-bind="menuProps" v-bind="menuProps"
:model-value="formatTimeDisplay(departureTimePart)" v-model="departureTimeInput"
label="Departure Time" label="Departure Time"
readonly
clearable clearable
prepend-inner-icon="mdi-clock-outline" prepend-inner-icon="mdi-clock-outline"
:placeholder="timeFormat === '24hr' ? 'e.g. 1430' : 'e.g. 2:30 PM'"
:disabled="!lookupComplete" :disabled="!lookupComplete"
@click:clear="clearDepartureTime" @click:clear="clearDepartureTime"
@blur="commitDepartureTimeInput"
@keydown.enter="commitDepartureTimeInput"
/> />
</template> </template>
<v-card> <v-card>
<v-time-picker <v-time-picker
v-model="departureTimePart" v-model="departureTimePart"
format="ampm" :format="timeFormat"
scrollable scrollable
/> />
<v-card-actions> <v-card-actions>
@@ -420,18 +531,21 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
<!-- 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">
<template #activator="{ props: menuProps }">
<v-text-field <v-text-field
v-bind="menuProps" v-model="arrivalDateValue"
:model-value="formatDateDisplay(arrivalDatePart)" type="date"
label="Arrival Date" label="Arrival Date"
readonly
clearable
prepend-inner-icon="mdi-calendar" prepend-inner-icon="mdi-calendar"
:disabled="!lookupComplete" :disabled="!lookupComplete"
:error-messages="submitForm.errors.arrival_date" :error-messages="submitForm.errors.arrival_date"
@click:clear="clearArrivalDate" >
<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> </template>
<v-date-picker <v-date-picker
@@ -441,25 +555,29 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
@update:model-value="arrDateMenu = false" @update:model-value="arrDateMenu = false"
/> />
</v-menu> </v-menu>
</template>
</v-text-field>
</v-col> </v-col>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-menu v-model="arrTimeMenu" :close-on-content-click="false"> <v-menu v-model="arrTimeMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }"> <template #activator="{ props: menuProps }">
<v-text-field <v-text-field
v-bind="menuProps" v-bind="menuProps"
:model-value="formatTimeDisplay(arrivalTimePart)" v-model="arrivalTimeInput"
label="Arrival Time" label="Arrival Time"
readonly
clearable clearable
prepend-inner-icon="mdi-clock-outline" prepend-inner-icon="mdi-clock-outline"
:placeholder="timeFormat === '24hr' ? 'e.g. 1430' : 'e.g. 2:30 PM'"
:disabled="!lookupComplete" :disabled="!lookupComplete"
@click:clear="clearArrivalTime" @click:clear="clearArrivalTime"
@blur="commitArrivalTimeInput"
@keydown.enter="commitArrivalTimeInput"
/> />
</template> </template>
<v-card> <v-card>
<v-time-picker <v-time-picker
v-model="arrivalTimePart" v-model="arrivalTimePart"
format="ampm" :format="timeFormat"
scrollable scrollable
/> />
<v-card-actions> <v-card-actions>
@@ -650,4 +768,8 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val
</template> </template>
<style scoped> <style scoped>
:deep(input[type="date"]::-webkit-calendar-picker-indicator) {
display: none;
-webkit-appearance: none;
}
</style> </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>
+2
View File
@@ -30,6 +30,8 @@ export interface UserSettings {
show_map_legend: boolean show_map_legend: boolean
hide_impossible_achievements: boolean hide_impossible_achievements: boolean
hide_map_filters: boolean hide_map_filters: boolean
time_format: "ampm" | "24hr"
date_format: string
} }