From c0b8d10addd6b58408d006556f00d1fffbc138be Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 21 Aug 2026 17:02:12 +1000 Subject: [PATCH 1/9] Make progressive achievements get notifications when complete --- app/Services/Achievements/AchievementService.php | 3 ++- bootstrap/app.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Services/Achievements/AchievementService.php b/app/Services/Achievements/AchievementService.php index f5b869f..9ab7d72 100644 --- a/app/Services/Achievements/AchievementService.php +++ b/app/Services/Achievements/AchievementService.php @@ -98,7 +98,8 @@ class AchievementService ->where('achievement_id', $achievement->id) ->first(); - $alreadyUnlocked = $existing !== null; + $alreadyUnlocked = $existing !== null + && (! $achievement->progressive || $existing->progress >= $achievement->threshold); UserAchievement::updateOrCreate( ['user_id' => $user->id, 'achievement_id' => $achievement->id], diff --git a/bootstrap/app.php b/bootstrap/app.php index f9260c0..5ead8d9 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -67,7 +67,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'message' => 'You don\'t have permission to access this page.', ], 404 => [ - 'title' => 'You Flight Has Been Cancelled', + 'title' => 'Your Flight Has Been Cancelled', 'message' => 'The page you are looking for doesn\'t exist or has been moved.', ], 419 => [ From 76118d0bfd7b380fb8690139ead694a1c9032edb Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 21 Aug 2026 20:52:06 +1000 Subject: [PATCH 2/9] Set default view to Profile --- app/Settings/SettingsRegistry.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Settings/SettingsRegistry.php b/app/Settings/SettingsRegistry.php index f6120a1..58d08c0 100644 --- a/app/Settings/SettingsRegistry.php +++ b/app/Settings/SettingsRegistry.php @@ -72,7 +72,7 @@ class SettingsRegistry 'type' => 'select', 'label' => 'Default Page', 'category' => 'FlightsGoneBy Settings', - 'default' => 'feed_first', + 'default' => 'profile', 'options' => [ ['value' => 'feed_first', 'label' => 'My Feed if Following People, My Profile if Not'], ['value' => 'profile', 'label' => 'My Profile'], From 5492cc72d79b58ea376ae6f32298935beb1df1f1 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 21 Aug 2026 21:40:02 +1000 Subject: [PATCH 3/9] Updated arrival date input on flight form --- resources/js/Pages/AddFlight.vue | 437 +++++++++++++++++++++++++------ 1 file changed, 353 insertions(+), 84 deletions(-) diff --git a/resources/js/Pages/AddFlight.vue b/resources/js/Pages/AddFlight.vue index 146ce90..0ee90ba 100644 --- a/resources/js/Pages/AddFlight.vue +++ b/resources/js/Pages/AddFlight.vue @@ -6,7 +6,7 @@ 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, SharedProps} from '@/Types/types' -import { ref, watch, computed } from 'vue' +import { ref, watch, computed, onMounted, nextTick } from 'vue' import { VTimePicker, VDatePicker } from 'vuetify/components' defineOptions({ layout: MainLayout }) @@ -49,25 +49,13 @@ const pad = (n: number) => String(n).padStart(2, '0') const flightNumber = ref(props.flight?.flight_number ?? '') const lookupLoading = ref(false) const lookupError = ref(null) -const lookupComplete = ref(true) -interface LookupResult { - airline_options: { value: number; title: string, logo_url: string }[] - from_options: { value: number; title: string; country_code: string }[] - to_options: { value: number; title: string; country_code: string }[] - aircraft_options: { value: number; title: string }[] - scheduled_departure_time: string | null - scheduled_arrival_time: string | null -} - -const lookupResult = ref(null) const lookupKey = ref(0) async function lookupFlight() { if (!flightNumber.value.trim()) return lookupLoading.value = true lookupError.value = null - lookupResult.value = null try { const response = await fetch(`${route('flights.lookup')}?number=${encodeURIComponent(flightNumber.value.trim())}`, { @@ -78,8 +66,6 @@ async function lookupFlight() { lookupError.value = data.message ?? 'Lookup failed.' return } - lookupResult.value = data - lookupComplete.value = true if (data.airline_options?.length) { airlineOptionsData.value = data.airline_options if (!form.airline) form.airline = data.airline_options[0] @@ -111,6 +97,12 @@ async function lookupFlight() { } suppressArrivalAutoFill = false + // NOTE: if the lookup API is ever extended to return a day + // difference for the arrival (e.g. `data.arrival_day_offset`), + // wire it in here: `arrivalOffset.value = data.arrival_day_offset` + // (clamped/mapped to 'custom' if outside -1..2). Left out for now + // since the API doesn't provide it yet. + lookupKey.value++ } catch (e) { lookupError.value = String(e) @@ -122,7 +114,6 @@ async function lookupFlight() { // ── Display form (drives the template) ─────────────────────────────────────── const form = useForm({ - flight_number: props.flight?.flight_number ?? '', departure_date: props.flight?.departure_date ?? '', arrival_date: props.flight?.arrival_date ?? '', from: props.flight?.from_options[0] ?? null as { value: number; title: string; country_code: string } | null, @@ -230,9 +221,15 @@ function combineDateTime(date: Date | null, time: string | null): string { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${(hh ?? '00').padStart(2, '0')}:${(mm ?? '00').padStart(2, '0')}` } -function clearDepartureDate() { departureDatePart.value = null } +// Whole-day difference between two Date objects (time-of-day ignored). +function diffInDays(from: Date | null, to: Date | null): number | null { + if (!from || !to) return null + const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()) + const b = new Date(to.getFullYear(), to.getMonth(), to.getDate()) + return Math.round((b.getTime() - a.getTime()) / 86_400_000) +} + 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", @@ -278,19 +275,88 @@ watch([arrivalDatePart, arrivalTimePart], () => { 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. +// ── Arrival day offset ─────────────────────────────────────────────────────── +// Rather than making the user pick an independent arrival date, arrival date +// is normally *derived* from departure date + a small relative offset +// (-1 / Same day / +1 / +2). "Custom" drops back to a real date picker for +// the rare case that falls outside that range. This avoids needing airport +// time zones to work out the arrival date, which a duration-based input +// would require. + +type ArrivalOffset = -1 | 0 | 1 | 2 + +const arrivalOffsetOptions: { value: ArrivalOffset; label: string }[] = [ + { value: -1, label: '−1 Day' }, + { value: 0, label: 'Same Day' }, + { value: 1, label: '+1 Day' }, + { value: 2, label: '+2 Days' }, +] + +const initialDiff = diffInDays(depInit.date, arrInit.date) +const initialDiffIsQuickOption = initialDiff !== null && ([-1, 0, 1, 2] as number[]).includes(initialDiff) + +// 'offset' (the default): arrival date is derived from departure date + the +// selected quick offset. 'custom': the user has dropped into a real date +// picker, e.g. because the actual gap is more than +2 days. +const arrivalDateMode = ref<'offset' | 'custom'>( + initialDiff !== null && !initialDiffIsQuickOption ? 'custom' : 'offset' +) + +const arrivalOffset = ref( + initialDiffIsQuickOption ? (initialDiff as ArrivalOffset) : 0 +) + +function applyArrivalOffset() { + if (suppressArrivalAutoFill) return + if (arrivalDateMode.value === 'custom') return + if (!departureDatePart.value) { + arrivalDatePart.value = null + return + } + const d = new Date(departureDatePart.value) + d.setDate(d.getDate() + arrivalOffset.value) + arrivalDatePart.value = d +} + +// Re-derive arrival date whenever departure date moves, or the offset is +// changed by hand. +watch(departureDatePart, applyArrivalOffset) +watch(arrivalOffset, applyArrivalOffset) + +function useCustomArrivalDate() { + arrivalDateMode.value = 'custom' +} + +function useQuickArrivalOffset() { + arrivalDateMode.value = 'offset' + applyArrivalOffset() +} + +function formatFullDate(d: Date | null): string { + if (!d) return '' + return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) +} + +// Auto-fill arrival TIME (+1hr) the first time a departure time is chosen +// (and a departure date is already set) and arrival time is still empty. +// If adding the hour rolls over midnight, bump the offset selector to match +// (e.g. departs 23:30 -> offset flips to "+1") so the resolved arrival date +// stays correct without the user having to notice and fix it themselves. watch(departureTimePart, (newTime) => { if (suppressArrivalAutoFill) return - if (!newTime || !departureDatePart.value || arrivalDatePart.value) return + if (arrivalDateMode.value === 'custom') return + if (!newTime || !departureDatePart.value || arrivalTimePart.value) return const dep = new Date(departureDatePart.value) const [h, m] = newTime.split(':').map(Number) dep.setHours(h, m) const arr = new Date(dep.getTime() + 60 * 60 * 1000) + const rawDiff = diffInDays(departureDatePart.value, arr) ?? 0 + const clampedDiff = Math.min(Math.max(rawDiff, -1), 2) as -1 | 0 | 1 | 2 + suppressArrivalAutoFill = true + arrivalOffset.value = clampedDiff arrivalDatePart.value = new Date(arr.getFullYear(), arr.getMonth(), arr.getDate()) arrivalTimePart.value = `${pad(arr.getHours())}:${pad(arr.getMinutes())}` suppressArrivalAutoFill = false @@ -313,11 +379,31 @@ const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.val // ── Native date inputs ─────────────────────────────────────────────────────── // departureDatePart / arrivalDatePart stay the source of truth as Date -// objects. These computed get/set pairs are what the native -// 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. +// objects. The elements below are deliberately left +// UNCONTROLLED from Vue's perspective — no v-model, no :model-value, no +// reactive :value binding of any kind. That's on purpose: any reactive +// binding through Vuetify's v-text-field — even one that only commits on +// blur/change, not every keystroke — still runs through v-text-field's own +// internal echo of the field, which re-renders the wrapped on every +// keystroke regardless of what we bind from outside. For type="date" +// specifically, that internal re-render resets the browser's own +// per-segment editing state mid-type (day/month/year), which is what was +// scrambling what you typed. Using a genuinely uncontrolled native +// (not wrapped in v-text-field at all) sidesteps that entirely. +// +// We grab a direct DOM ref to each native input and manage its .value +// imperatively instead: +// - once on mount, to show the initial value +// - whenever departureDatePart/arrivalDatePart changes from elsewhere +// (the calendar picker, a flight lookup, auto-fill) — but only if the +// field isn't currently focused, so we never fight with active typing +// - the native `change` event (fires once a full date has been entered +// and committed, or cleared) is what flows the typed value back into +// departureDatePart/arrivalDatePart +// This keeps the real native, locale-aware date input and picker UI intact. +// +// The arrival native date input only exists in the DOM while arrivalOffset +// is 'custom' (see template); its value is (re)synced whenever it mounts. function toIsoDateString(d: Date | null): string { if (!d) return '' @@ -331,16 +417,42 @@ function fromIsoDateString(value: string): Date | null { return new Date(y, m - 1, d) } -const departureDateValue = computed({ - get: () => toIsoDateString(departureDatePart.value), - set: (value: string) => { departureDatePart.value = fromIsoDateString(value) }, +const departureDateEl = ref(null) +const arrivalDateEl = ref(null) + +onMounted(() => { + if (departureDateEl.value) departureDateEl.value.value = toIsoDateString(departureDatePart.value) + if (arrivalDateEl.value) arrivalDateEl.value.value = toIsoDateString(arrivalDatePart.value) }) -const arrivalDateValue = computed({ - get: () => toIsoDateString(arrivalDatePart.value), - set: (value: string) => { arrivalDatePart.value = fromIsoDateString(value) }, +watch(departureDatePart, (d) => { + const el = departureDateEl.value + if (el && document.activeElement !== el) el.value = toIsoDateString(d) }) +watch(arrivalDatePart, (d) => { + const el = arrivalDateEl.value + if (el && document.activeElement !== el) el.value = toIsoDateString(d) +}) + +// The arrival native input only exists while arrivalDateMode === 'custom', +// so re-sync its value whenever it (re)mounts into the DOM. +watch(arrivalDateMode, async (mode) => { + if (mode !== 'custom') return + await nextTick() + if (arrivalDateEl.value) arrivalDateEl.value.value = toIsoDateString(arrivalDatePart.value) +}) + +function commitDepartureDateInput(e: Event) { + const value = (e.target as HTMLInputElement).value + departureDatePart.value = value ? fromIsoDateString(value) : null +} + +function commitArrivalDateInput(e: Event) { + const value = (e.target as HTMLInputElement).value + arrivalDatePart.value = value ? fromIsoDateString(value) : null +} + // ── Editable, format-aware time text fields ──────────────────────────────── // departureTimePart / arrivalTimePart stay the source of truth as internal // "HH:mm" (24hr) strings. These input refs are what the text-fields actually @@ -472,30 +584,33 @@ function commitArrivalTimeInput() { - - + + + +
+ {{ submitForm.errors.departure_date }} +
@@ -507,7 +622,6 @@ function commitArrivalTimeInput() { 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" @@ -528,23 +642,59 @@ function commitArrivalTimeInput() {
- + - - + +
+ {{ submitForm.errors.arrival_date }} +
@@ -568,7 +729,6 @@ function commitArrivalTimeInput() { 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" @@ -597,7 +757,6 @@ function commitArrivalTimeInput() { label="From" :prefilled-options="fromOptionsData" :error-messages="submitForm.errors.from_id" - :disabled="!lookupComplete" />
@@ -610,7 +769,6 @@ function commitArrivalTimeInput() { label="To" :prefilled-options="toOptionsData" :error-messages="submitForm.errors.to_id" - :disabled="!lookupComplete" /> @@ -623,7 +781,6 @@ function commitArrivalTimeInput() { v-model="form.airline" :prefilled-options="airlineOptionsData" :error-messages="submitForm.errors.airline_id" - :disabled="!lookupComplete" /> @@ -636,7 +793,6 @@ function commitArrivalTimeInput() { v-model="form.aircraft" :prefilled-options="aircraftOptionsData" :error-messages="submitForm.errors.aircraft_id" - :disabled="!lookupComplete" /> @@ -648,7 +804,6 @@ function commitArrivalTimeInput() { v-model="form.aircraft_registration" label="Aircraft Registration" placeholder="e.g. VH-OQA" - :disabled="!lookupComplete" :error-messages="submitForm.errors.aircraft_registration" /> @@ -660,7 +815,6 @@ function commitArrivalTimeInput() { item-title="name" item-value="id" :return-object="true" - :disabled="!lookupComplete" :error-messages="submitForm.errors.flight_class_id" /> @@ -673,7 +827,6 @@ function commitArrivalTimeInput() { v-model="form.seat_number" label="Seat Number" placeholder="e.g. 12A" - :disabled="!lookupComplete" :error-messages="submitForm.errors.seat_number" /> @@ -685,7 +838,6 @@ function commitArrivalTimeInput() { item-title="name" item-value="id" :return-object="true" - :disabled="!lookupComplete" :error-messages="submitForm.errors.seat_type_id" /> @@ -701,7 +853,6 @@ function commitArrivalTimeInput() { item-title="name" item-value="id" :return-object="true" - :disabled="!lookupComplete" :error-messages="submitForm.errors.flight_reason_id" /> @@ -713,7 +864,6 @@ function commitArrivalTimeInput() { item-title="name" item-value="id" :return-object="true" - :disabled="!lookupComplete" :error-messages="submitForm.errors.crew_type_id" /> @@ -726,7 +876,6 @@ function commitArrivalTimeInput() { v-model="form.note" label="Note" placeholder="Any additional notes…" - :disabled="!lookupComplete" :error-messages="submitForm.errors.note" rows="3" auto-grow @@ -740,7 +889,6 @@ function commitArrivalTimeInput() { @@ -754,7 +902,7 @@ function commitArrivalTimeInput() { block size="large" :loading="submitForm.processing" - :disabled="!lookupComplete || submitForm.processing" + :disabled="submitForm.processing" @click="submit" > {{ isEdit ? 'Save Changes' : 'Add Flight' }} @@ -768,8 +916,129 @@ function commitArrivalTimeInput() { From 3b4ded2fabc074730229a5bb56110e64af586eca Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 21 Aug 2026 21:45:52 +1000 Subject: [PATCH 4/9] Fixed notification menu on mobile --- .../FlightsGoneBy/NotificationMenu.vue | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/resources/js/Components/FlightsGoneBy/NotificationMenu.vue b/resources/js/Components/FlightsGoneBy/NotificationMenu.vue index b65d9c5..8c77a13 100644 --- a/resources/js/Components/FlightsGoneBy/NotificationMenu.vue +++ b/resources/js/Components/FlightsGoneBy/NotificationMenu.vue @@ -1,5 +1,5 @@