diff --git a/lib/constants.dart b/lib/constants.dart index 05a72cf..3dffe82 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -10,3 +10,10 @@ const String kSupabaseEndpoint = "https://fbgvisimvgeksfxpemuk.supabase.co"; /// Leave empty to disable Supabase initialization. const String kSupabasePublishableKey = "sb_publishable_tqhag58YEhNrIHy264JsKg_T1VCIZib"; + +/* + * APP + */ + +/// The app's hostname. +const String kAppHostname = "road.imbenji.net"; diff --git a/lib/pages/home/channels/operations_channel_view.dart b/lib/pages/home/channels/operations_channel_view.dart index 48f8230..c08b9e8 100644 --- a/lib/pages/home/channels/operations_channel_view.dart +++ b/lib/pages/home/channels/operations_channel_view.dart @@ -25,6 +25,7 @@ class _OperationsChannelViewState extends State { String? _scheduleId; List _duties = const []; + List _busWorkNumbers = const []; List<({String tripNumber, String dutyNumber})> _tripMeta = const []; List _stopNames = const []; Map _aliases = const {}; @@ -33,6 +34,7 @@ class _OperationsChannelViewState extends State { Map filterValue = {}; List? _dutyTrips; + List? _busTrips; OperationsTripSnapshot? _tripSnapshot; List<({OperationsTrip trip, String? time})>? _stopSchedule; bool _stopHasMore = false; @@ -42,6 +44,7 @@ class _OperationsChannelViewState extends State { List _stopRowKeys = []; final ScrollController _stopScrollController = ScrollController(); + final GlobalKey _stopScrollViewKey = GlobalKey(); @override @@ -103,6 +106,7 @@ class _OperationsChannelViewState extends State { final scheduleId = (data["schedule_id"] ?? "").toString(); final duties = List.from(data["duties"] as List? ?? []); + final busWorkNumbers = List.from(data["bus_work_numbers"] as List? ?? []); final tripMetaRaw = data["trips"] as List? ?? []; final tripMeta = tripMetaRaw.map((t) { @@ -132,6 +136,7 @@ class _OperationsChannelViewState extends State { setState(() { _scheduleId = scheduleId; _duties = duties; + _busWorkNumbers = busWorkNumbers; _tripMeta = tripMeta; _stopNames = stopNames; _aliases = aliases; @@ -231,6 +236,83 @@ class _OperationsChannelViewState extends State { } } + Future _loadBusDetail(String busWorkNumber) async { + final scheduleId = _scheduleId; + if (scheduleId == null) return; + + setState(() { + _loadingDetail = true; + _busTrips = null; + }); + + try { + final response = await _invoke("operations-bus-detail", { + "channel_id": widget.channel.id, + "schedule_id": scheduleId, + "bus_work_number": busWorkNumber, + }); + + final data = response.data as Map?; + final tripsRaw = data?["trips"] as List? ?? []; + + final snapshots = []; + for (final tripData in tripsRaw) { + final m = tripData as Map; + final trip = OperationsTrip( + id: (m["id"] ?? "").toString(), + scheduleId: scheduleId, + tripNumber: (m["trip_number"] ?? "").toString(), + dutyNumber: (m["duty_number"] ?? "").toString(), + busWorkNumber: (m["bus_work_number"] ?? "").toString(), + direction: (m["direction"] ?? "").toString(), + sortOrder: (m["sort_order"] as num?)?.toInt() ?? 0, + ); + + final stopsRaw = m["stops"] as List? ?? []; + final stops = stopsRaw.map((s) { + final sm = s as Map; + final name = (sm["stop_name"] ?? "").toString().trim(); + final aliasEntry = _aliases[Stop.normalizeName(name)]; + return OperationsScheduledStop( + id: (sm["id"] ?? "").toString(), + tripId: trip.id, + sequence: (sm["stop_sequence"] as num?)?.toInt() ?? 0, + name: name, + alias: aliasEntry?.alias, + aliasSource: aliasEntry?.source, + scheduledTime: (sm["scheduled_time"] ?? "").toString().trim().isEmpty + ? null + : sm["scheduled_time"].toString(), + ); + }).toList() + ..sort((a, b) => a.sequence.compareTo(b.sequence)); + + snapshots.add(OperationsTripSnapshot( + trip: trip, + stops: stops.map((s) => OperationsStop(scheduledStop: s)).toList(), + scheduledStops: stops, + )); + } + + snapshots.sort((a, b) { + final aTime = a.scheduledStops.firstOrNull?.scheduledTime ?? ""; + final bTime = b.scheduledStops.firstOrNull?.scheduledTime ?? ""; + return aTime.compareTo(bTime); + }); + + if (!mounted) return; + setState(() { + _busTrips = snapshots; + _loadingDetail = false; + }); + } catch (error, stackTrace) { + debugPrint("[OperationsChannelView] loadBusDetail failed: $error"); + debugPrintStack(stackTrace: stackTrace); + if (!mounted) return; + setState(() => _loadingDetail = false); + } + } + Future _loadTripDetail(String tripNumber) async { final scheduleId = _scheduleId; if (scheduleId == null) return; @@ -403,15 +485,25 @@ class _OperationsChannelViewState extends State { } } - // each row is roughly 61px tall (10 vertical padding top+bottom + ~41 content) - const rowHeight = 61.0; - final offset = (bestIndex * rowHeight).clamp( - 0.0, + if (bestIndex >= _stopRowKeys.length) return; + final rowCtx = _stopRowKeys[bestIndex].currentContext; + if (rowCtx == null) return; + + final scrollCtx = _stopScrollViewKey.currentContext; + if (scrollCtx == null) return; + + final rowBox = rowCtx.findRenderObject() as RenderBox?; + final scrollBox = scrollCtx.findRenderObject() as RenderBox?; + if (rowBox == null || scrollBox == null) return; + + final rowOffset = rowBox.localToGlobal(Offset.zero, ancestor: scrollBox); + final target = (_stopScrollController.offset + rowOffset.dy).clamp( + _stopScrollController.position.minScrollExtent, _stopScrollController.position.maxScrollExtent, ); _stopScrollController.animateTo( - offset, + target, duration: const Duration(milliseconds: 350), curve: Curves.easeOut, ); @@ -532,6 +624,7 @@ class _OperationsChannelViewState extends State { Divider(), Expanded( child: SingleChildScrollView( + key: _stopScrollViewKey, controller: _stopScrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -542,8 +635,12 @@ class _OperationsChannelViewState extends State { child: Text("No scheduled times found for this stop.").small.muted, ) else - ...schedules.map( - (row) => Container( + ...schedules.indexed.map( + (entry) { + final (i, row) = entry; + final rowKey = i < _stopRowKeys.length ? _stopRowKeys[i] : null; + return Container( + key: rowKey, padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( border: Border( @@ -592,7 +689,8 @@ class _OperationsChannelViewState extends State { Gap(2), ], ), - ), + ); + }, ), if (_stopLoadingMore) @@ -608,6 +706,75 @@ class _OperationsChannelViewState extends State { ); } + // By Bus + if (filterBy == "By Bus") { + final selectedBus = (filterValue["By Bus"] ?? "").toString(); + if (selectedBus.isEmpty) { + return Center(child: Text("Select a bus to preview.").small.muted); + } + + final snapshots = _busTrips ?? const []; + if (snapshots.isEmpty) { + return Center(child: Text("No trips found for this bus.").small.muted); + } + + return Column( + children: [ + Gap(8), + Divider(), + Container( + height: 50, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(LucideIcons.busFront), + Gap(10), + Text("Bus $selectedBus".toUpperCase()).extraBold.textSmall, + ], + ), + ), + Divider(), + Gap(2), + Divider(), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < snapshots.length; i++) ...[ + if (i != 0) ...[ + Divider(), + Gap(2), + Divider(), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(LucideIcons.bus), + Gap(8), + Text( + "Trip ${snapshots[i].trip.tripNumber} • Duty ${snapshots[i].trip.dutyNumber}", + ).semiBold.textSmall, + ], + ), + ), + Divider(), + TripDiagram( + lineColor: Colors.red, + entries: _diagramEntries(snapshots[i].scheduledStops), + leftOffset: 12, + rowHeight: 48, + ), + ], + ], + ), + ), + ), + ], + ); + } + // By Duty if (filterBy == "By Duty") { final selectedDuty = (filterValue["By Duty"] ?? "").toString(); @@ -754,6 +921,7 @@ class _OperationsChannelViewState extends State { items: SelectItemList( children: [ SelectItemButton(value: "By Duty", child: Text("By Duty")), + SelectItemButton(value: "By Bus", child: Text("By Bus")), SelectItemButton(value: "By Trip", child: Text("By Trip")), SelectItemButton(value: "By Stop", child: Text("By Stop")), ], @@ -771,6 +939,8 @@ class _OperationsChannelViewState extends State { itemBuilder: (context, item) { if (filterBy == "By Duty") return Text("Duty $item"); + if (filterBy == "By Bus") return Text("Bus $item"); + if (filterBy == "By Trip") { final tripNumber = item.toString(); final match = _tripMeta.where((t) => t.tripNumber == tripNumber).firstOrNull; @@ -791,11 +961,14 @@ class _OperationsChannelViewState extends State { setState(() { filterValue[filterBy] = value; _dutyTrips = null; + _busTrips = null; _tripSnapshot = null; _stopSchedule = null; }); if (filterBy == "By Duty") { unawaited(_loadDutyDetail(value.toString())); + } else if (filterBy == "By Bus") { + unawaited(_loadBusDetail(value.toString())); } else if (filterBy == "By Trip") { unawaited(_loadTripDetail(value.toString())); } else if (filterBy == "By Stop") { @@ -813,6 +986,11 @@ class _OperationsChannelViewState extends State { value: d, child: Text(d), )).toList(); + } else if (filterBy == "By Bus") { + items = _busWorkNumbers.map((b) => SelectItemButton( + value: b, + child: Text(b), + )).toList(); } else if (filterBy == "By Trip") { final trips = List.from(_tripMeta); trips.sort((a, b) { diff --git a/lib/pages/home/widgets/home_dialogs.dart b/lib/pages/home/widgets/home_dialogs.dart index 8490e7d..27da11c 100644 --- a/lib/pages/home/widgets/home_dialogs.dart +++ b/lib/pages/home/widgets/home_dialogs.dart @@ -1,5 +1,6 @@ import "dart:convert"; +import "package:bus_running_record/constants.dart"; import "package:bus_running_record/provider/collaboration_state.dart"; import "package:bus_running_record/provider/supabase_state.dart"; import "package:provider/provider.dart"; @@ -136,7 +137,7 @@ Future showJoinOrganizationDialog(BuildContext context) async { constraints: const BoxConstraints(maxWidth: 420), child: TextField( autofocus: true, - placeholder: const Text("https://.../#/invite/"), + placeholder: Text("https://$kAppHostname/#/invite/"), onChanged: (value) { inviteInput = value; }, @@ -152,6 +153,13 @@ Future showJoinOrganizationDialog(BuildContext context) async { onPressed: () => Navigator.of(dialogContext).pop(), child: const Text("Cancel"), ), + Button.text( + onPressed: () { + Navigator.of(dialogContext).pop(); + showCreateOrganizationDialog(context); + }, + child: const Text("Create one instead"), + ), Button.primary( onPressed: () => Navigator.of(dialogContext).pop(inviteInput), child: const Text("Join"), diff --git a/lib/pages/home/widgets/home_left_sidebar.dart b/lib/pages/home/widgets/home_left_sidebar.dart index d0725cc..6f01661 100644 --- a/lib/pages/home/widgets/home_left_sidebar.dart +++ b/lib/pages/home/widgets/home_left_sidebar.dart @@ -48,7 +48,7 @@ class HomeLeftSidebar extends StatelessWidget { aspectRatio: 1, child: IconButton.outline( onPressed: () { - unawaited(showCreateOrganizationDialog(context)); + unawaited(showJoinOrganizationDialog(context)); }, shape: ButtonShape.circle, size: ButtonSize.normal, @@ -86,62 +86,79 @@ class HomeLeftSidebar extends StatelessWidget { Expanded( child: Container( color: Theme.of(context).colorScheme.background, - child: IntrinsicWidth( - child: Column( + child: IntrinsicWidth(child: Column( crossAxisAlignment: CrossAxisAlignment.start, + children: [ - Gap(topPadding), - Padding( - padding: const EdgeInsets.all(8), - child: SizedBox( - width: double.infinity, - child: Button.ghost( - alignment: Alignment.center, - leading: const Icon(Icons.add), - child: const Text("Create Organization"), - onPressed: () { - unawaited(showCreateOrganizationDialog(context)); - }, - ), + + // Organisation Name + Container( + padding: const EdgeInsets.all(8.0), + height: 170, + alignment: Alignment.bottomLeft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + + Padding( + padding: const EdgeInsets.only(left: 8), + child: Transform.translate( + offset: const Offset(0, 2), + child: Text( + collab.organizations.where((o) => o.id == collab.selectedOrganizationId).map((o) => o.name).firstOrNull ?? "Select a server", + style: const TextStyle(height: 1), + ).extraBold.large, + ), + ), + + Builder( + builder: (btnContext) => IconButton.ghost( + icon: Icon(LucideIcons.ellipsisVertical), + onPressed: () { + final orgId = collab.selectedOrganizationId; + if (orgId == null) return; + final org = collab.organizations.where((o) => o.id == orgId).firstOrNull; + if (org == null) return; + + showDropdown( + context: btnContext, + anchorAlignment: Alignment.bottomRight, + alignment: Alignment.topLeft, + builder: (menuContext) { + return DropdownMenu( + children: [ + MenuLabel(child: Text(org.name).small.semiBold), + const MenuDivider(), + MenuButton( + leading: const Icon(LucideIcons.plus).iconSmall, + onPressed: (_) { + unawaited(showCreateChannelDialog(context, organizationId: org.id)); + }, + child: const Text("Add Channel"), + ), + const MenuDivider(), + MenuButton( + leading: const Icon(LucideIcons.settings2).iconSmall, + onPressed: (_) { + context.go("/org/${org.id}/settings"); + }, + child: const Text("Settings"), + ), + ], + ); + }, + ); + }, + ), + ), + ], ), ), - Padding( - padding: const EdgeInsets.only( - left: 8, - right: 8, - bottom: 8, - ), - child: SizedBox( - width: double.infinity, - child: Button.ghost( - alignment: Alignment.center, - leading: const Icon(LucideIcons.userRoundPlus), - child: const Text("Join Organization"), - onPressed: () { - unawaited(showJoinOrganizationDialog(context)); - }, - ), - ), - ), - Padding( - padding: const EdgeInsets.only( - left: 8, - right: 8, - bottom: 8, - ), - child: SizedBox( - width: double.infinity, - child: Button.secondary( - alignment: Alignment.center, - leading: const Icon(LucideIcons.bug), - child: const Text("Auth Debug"), - onPressed: () { - unawaited(runAuthDebug(context)); - }, - ), - ), - ), - const Divider(), + + + Divider(), + Expanded( child: Padding( padding: const EdgeInsets.only( @@ -150,14 +167,34 @@ class HomeLeftSidebar extends StatelessWidget { right: 16, bottom: 8, ), - child: _OrganizationList( + child: _SelectedOrgChannelList( organizations: organizations, + selectedOrganizationId: collab.selectedOrganizationId, isLoading: collab.isLoadingOrganizations, errorMessage: collab.errorMessage, ), ), ), const Divider(), + if (false) + Padding( + padding: const EdgeInsets.only( + left: 8, + right: 8, + bottom: 8, + ), + child: SizedBox( + width: double.infinity, + child: Button.secondary( + alignment: Alignment.center, + leading: const Icon(LucideIcons.bug), + child: const Text("Auth Debug"), + onPressed: () { + unawaited(runAuthDebug(context)); + }, + ), + ), + ), Padding( padding: const EdgeInsets.all(8), child: Button.ghost( @@ -165,7 +202,7 @@ class HomeLeftSidebar extends StatelessWidget { children: [ Avatar(initials: initials), const Gap(8), - Expanded(child: Basic(title: Text(displayName))), + Basic(title: Text(displayName)), const Icon(LucideIcons.logOut).iconSmall, ], ), @@ -175,8 +212,7 @@ class HomeLeftSidebar extends StatelessWidget { ), ), ], - ), - ), + )), ), ), ], @@ -184,14 +220,16 @@ class HomeLeftSidebar extends StatelessWidget { } } -class _OrganizationList extends StatelessWidget { - const _OrganizationList({ +class _SelectedOrgChannelList extends StatelessWidget { + const _SelectedOrgChannelList({ required this.organizations, + required this.selectedOrganizationId, required this.isLoading, required this.errorMessage, }); final List organizations; + final String? selectedOrganizationId; final bool isLoading; final String? errorMessage; @@ -207,26 +245,26 @@ class _OrganizationList extends StatelessWidget { ); } - if (organizations.isEmpty) { + if (selectedOrganizationId == null || organizations.isEmpty) { return Center( - child: Text("No organizations yet. Create one to get started.").small, + child: Text("Select a server to view channels.").small.muted, ); } final collab = context.watch(); - return ListView.separated( - itemBuilder: (context, index) { - final org = organizations[index]; - return _OrganizationGroup( - organization: org, - channels: collab.channelsForOrganization(org.id), - selectedOrganizationId: collab.selectedOrganizationId, - selectedChannelId: collab.selectedChannelId, - ); - }, - separatorBuilder: (context, index) => const Gap(4), - itemCount: organizations.length, + final matchingOrgs = organizations.where((o) => o.id == selectedOrganizationId); + if (matchingOrgs.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + final org = matchingOrgs.first; + + return _OrganizationGroup( + organization: org, + channels: collab.channelsForOrganization(org.id), + selectedOrganizationId: collab.selectedOrganizationId, + selectedChannelId: collab.selectedChannelId, ); } } @@ -329,6 +367,19 @@ class _ServerRail extends StatelessWidget { items: [ MenuLabel(child: Text(organization.name)), const MenuDivider(), + MenuButton( + leading: const Icon(LucideIcons.plus).iconSmall, + onPressed: (_) { + unawaited( + showCreateChannelDialog( + context, + organizationId: organization.id, + ), + ); + }, + child: const Text("Add Channel"), + ), + const MenuDivider(), MenuButton( leading: const Icon(LucideIcons.settings2).iconSmall, onPressed: (_) { diff --git a/lib/pages/org_settings/page.dart b/lib/pages/org_settings/page.dart index 52daef4..6ff424d 100644 --- a/lib/pages/org_settings/page.dart +++ b/lib/pages/org_settings/page.dart @@ -1,5 +1,6 @@ import "dart:async"; +import "package:bus_running_record/constants.dart"; import "package:bus_running_record/provider/collaboration_state.dart"; import "package:file_picker/file_picker.dart"; import "package:flutter/services.dart"; @@ -534,11 +535,7 @@ class _OrganizationSettingsPageState extends State { organizationId: organizationId, ); if (!mounted) return; - final base = Uri.base; - final origin = base.hasAuthority - ? "${base.scheme}://${base.authority}" - : ""; - final inviteLink = "$origin/#/invite/$token"; + final inviteLink = "https://$kAppHostname/#/invite/$token"; setState(() { _inviteLink = inviteLink; _message = "Invite link generated."; diff --git a/serve_web.sh b/serve_web.sh new file mode 100755 index 0000000..dda7b28 --- /dev/null +++ b/serve_web.sh @@ -0,0 +1,2 @@ +#!/bin/bash +flutter run -d web-server --web-port 8080 diff --git a/supabase/config.toml b/supabase/config.toml index 2267fe1..259dd71 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -48,3 +48,6 @@ verify_jwt = false [functions.operations-stop-detail] verify_jwt = false + +[functions.operations-bus-detail] +verify_jwt = false diff --git a/supabase/functions/operations-bus-detail/index.ts b/supabase/functions/operations-bus-detail/index.ts new file mode 100644 index 0000000..c515d4b --- /dev/null +++ b/supabase/functions/operations-bus-detail/index.ts @@ -0,0 +1,81 @@ +import { fail, handleOptions, json } from "../_shared/http.ts"; +import { requireUser } from "../_shared/supabase.ts"; + +Deno.serve(async (req) => { + const preflight = handleOptions(req); + if (preflight) return preflight; + + if (req.method !== "POST") return fail("Method not allowed", 405); + + const { client, user, error: userError } = await requireUser(req); + if (!user) return fail(userError ?? "Unauthorized", 401); + + let body: { channel_id?: string; schedule_id?: string; bus_work_number?: string }; + try { + body = await req.json(); + } catch { + return fail("Invalid JSON body"); + } + + const channelId = (body.channel_id ?? "").trim(); + const scheduleId = (body.schedule_id ?? "").trim(); + const busWorkNumber = (body.bus_work_number ?? "").trim(); + + if (!channelId) return fail("channel_id is required"); + if (!scheduleId) return fail("schedule_id is required"); + if (!busWorkNumber) return fail("bus_work_number is required"); + + const { data: schedule, error: scheduleError } = await client + .from("operations_schedules") + .select("id, channel_id") + .eq("id", scheduleId) + .eq("channel_id", channelId) + .maybeSingle(); + + if (scheduleError) return fail(scheduleError.message, 400); + if (!schedule) return fail("forbidden", 403); + + const { data: tripRows, error: tripError } = await client + .from("operations_trips") + .select("id, trip_number, duty_number, bus_work_number, direction, sort_order") + .eq("schedule_id", scheduleId) + .eq("bus_work_number", busWorkNumber) + .order("sort_order", { ascending: true }); + + if (tripError) return fail(tripError.message, 500); + + const trips = (tripRows ?? []) as { + id: string; + trip_number: string; + duty_number: string; + bus_work_number: string; + direction: string; + sort_order: number; + }[]; + + if (trips.length === 0) return json({ trips: [] }); + + const tripIds = trips.map((t) => t.id); + + const { data: stopRows, error: stopError } = await client + .from("operations_trip_stops") + .select("id, trip_id, stop_sequence, stop_name, scheduled_time") + .in("trip_id", tripIds) + .order("stop_sequence", { ascending: true }); + + if (stopError) return fail(stopError.message, 500); + + const stopsByTripId: Record = {}; + for (const stop of stopRows ?? []) { + const s = stop as { trip_id: string }; + if (!stopsByTripId[s.trip_id]) stopsByTripId[s.trip_id] = []; + stopsByTripId[s.trip_id]!.push(stop); + } + + const result = trips.map((trip) => ({ + ...trip, + stops: stopsByTripId[trip.id] ?? [], + })); + + return json({ trips: result }); +}); diff --git a/supabase/functions/operations-schedule-meta/index.ts b/supabase/functions/operations-schedule-meta/index.ts index aa1f3c6..cca2b47 100644 --- a/supabase/functions/operations-schedule-meta/index.ts +++ b/supabase/functions/operations-schedule-meta/index.ts @@ -63,6 +63,7 @@ Deno.serve(async (req) => { }[]; const duties = [...new Set(trips.map((t) => t.duty_number))].sort(); + const busWorkNumbers = [...new Set(trips.map((t) => t.bus_work_number))].sort(); const tripMeta = trips.map((t) => ({ trip_number: t.trip_number, @@ -109,6 +110,7 @@ Deno.serve(async (req) => { has_schedule: true, schedule_id: scheduleId, duties, + bus_work_numbers: busWorkNumbers, trips: tripMeta, stop_names: stopNames, aliases, diff --git a/supabase/functions/operations-stop-alias-enhance/index.ts b/supabase/functions/operations-stop-alias-enhance/index.ts index 537f378..0866efa 100644 --- a/supabase/functions/operations-stop-alias-enhance/index.ts +++ b/supabase/functions/operations-stop-alias-enhance/index.ts @@ -14,24 +14,23 @@ type OpenAiAlias = { estimated?: string; }; -const prompt = `You are interpreting abbreviated station names from a rail replacement service display. +const prompt = `You are interpreting abbreviated stop names from a bus schedule display. Each entry follows this structure: -* A 4-letter station code (derived from the real station name, often by removing vowels or compressing syllables) -* A 2-letter stop code (ignore this; it is ambiguous and not needed) +* A short stop code (typically 4 letters, derived from the stop name by removing vowels or compressing syllables) +* A 2-letter zone or bay code (ignore this; it is not needed) * An optional "T" indicating terminus (ignore for naming purposes) -Your task is to infer the full station names from the 4-letter codes. +Your task is to infer the full stop names from the short codes. Guidelines: -* Treat the 4-letter code as a compressed version of a real station name (e.g. consonant-heavy, missing vowels, or merged syllables) -* Use pattern recognition rather than strict decoding -* Prefer real-world plausibility over perfect letter matching -* Assume all stations are on the same rail corridor or geographically connected route -* Use the sequence of stops to inform your guesses (adjacent stations should make sense geographically) -* If a code is slightly irregular, prioritise what fits the route best over what matches the letters exactly +* Treat the code as a compressed version of a stop or locality name +* Use pattern recognition rather than strict letter matching +* Do not assume any specific country, region or city — infer purely from the codes and their sequence +* Use the sequence of stops to inform your guesses (adjacent stops should make sense as a connected route) +* If a code is ambiguous, prefer the interpretation that best fits the surrounding stops Output: Return JSON with shape {"aliases":[{"original":"","estimated":""}]}.