neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
Log in to mark this repository.
Put the live data on the map, and say what it is
31a0a77 · Gabriel Morell · 2026-09-01 14:33
Message
{commit_body(@commit)}
Files changed
modified
apps/room_gbfs/lib/room_gbfs/gbfs_worker.ex
+14
−0
| 61 | 61 | |> GenServer.cast(:update_realtime) |
| 62 | 62 | end |
| 63 | 63 | |
| 64 | + # A station query answers with the dock; an area query answers with whatever | |
| 65 | + # is loose within its radius. Same source, same query struct, different | |
| 66 | + # question -- see RoomSanctum.Configuration.Queries.GBFS. | |
| 67 | + def query_stop(id, %{mode: :area} = query) do | |
| 68 | + bikes = Storage.free_bikes_near_foci(id, query.foci_id, query.radius) | |
| 69 | + | |
| 70 | + # Docks second, so a mixed answer still leads with what is loose nearby -- | |
| 71 | + # which is the thing an area query was asked about. | |
| 72 | + case Map.get(query, :include_docks) do | |
| 73 | + true -> bikes ++ Storage.stations_near_foci(id, query.foci_id, query.radius) | |
| 74 | + _ -> bikes | |
| 75 | + end | |
| 76 | + end | |
| 77 | + | |
| 64 | 78 | def query_stop(id, query) do |
| 65 | 79 | [Storage.get_current_information_for_bikestop(id, query.stop_id)] |
| 66 | 80 | end |
modified
apps/room_sanctum/assets/js/leaflet/basemap.js
+15
−0
| 150 | 150 | window.addEventListener('phx:set-theme', () => setTimeout(repaint, 0)) |
| 151 | 151 | } |
| 152 | 152 | |
| 153 | +/** | |
| 154 | + * Run `fn` whenever the theme changes, and return an unsubscribe. | |
| 155 | + * | |
| 156 | + * The tint keeps a registry of repaint callbacks and watches the document for | |
| 157 | + * both the attribute write and the picker's own event; anything else that has | |
| 158 | + * to restyle on a theme flip -- markers shaded by a value, whose ramp is | |
| 159 | + * anchored to the surface they sit on -- wants exactly the same signal rather | |
| 160 | + * than a second observer beside it. | |
| 161 | + */ | |
| 162 | +export function onThemeChange(fn) { | |
| 163 | + registry.add(fn) | |
| 164 | + watchTheme() | |
| 165 | + return () => registry.delete(fn) | |
| 166 | +} | |
| 167 | + | |
| 153 | 168 | /** |
| 154 | 169 | * Add a basemap to `map` and wash it in the current theme colour. |
| 155 | 170 | * |
modified
apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+187
−10
| 4 | 4 | // document cannot reach -- without this Leaflet's tiles have no positioning |
| 5 | 5 | // rules and lay themselves out as a grid of loose images. |
| 6 | 6 | import leafletCss from 'leaflet/dist/leaflet.css' |
| 7 | −import { addBasemap } from './basemap' | |
| 7 | +import { addBasemap, isDarkTheme, onThemeChange } from './basemap' | |
| 8 | 8 | import { addFullscreenControl } from './fullscreen' |
| 9 | 9 | |
| 10 | 10 | const template = document.createElement('template'); |
| 45 | 45 | ` |
| 46 | 46 | |
| 47 | 47 | |
| 48 | +// How full a bike is, as depth of green. | |
| 49 | +// | |
| 50 | +// Sequential encoding: one hue, monotone lightness, so the reader sees "more" | |
| 51 | +// without having to learn a key -- and green stays the bikes' own hue, which is | |
| 52 | +// what tells them from the blue docks and amber queries beside them. Four steps | |
| 53 | +// rather than a continuous scale: a shade is read by comparison with its | |
| 54 | +// neighbours, and four are tellable apart where forty are not. | |
| 55 | +// | |
| 56 | +// Two ramps because the map has two surfaces. On a light basemap the scale runs | |
| 57 | +// light-to-dark, empty to full; on a dark one it is anchored the other way, or | |
| 58 | +// an empty bike would be a black dot on a black map. Each was checked against | |
| 59 | +// the surface it renders on: every step holds its own against the basemap, and | |
| 60 | +// the palest end still clears the 2:1 floor. | |
| 61 | +const CHARGE_RAMP = { | |
| 62 | + light: ['#22c55e', '#16a34a', '#15803d', '#14532d'], | |
| 63 | + dark: ['#15803d', '#16a34a', '#22c55e', '#4ade80'] | |
| 64 | +} | |
| 65 | + | |
| 66 | +// A bike whose feed reports neither charge nor range. Neutral rather than a | |
| 67 | +// step of the ramp: any green here is a claim about how full it is, and the | |
| 68 | +// second step in particular would make an unknown bike indistinguishable from | |
| 69 | +// a half-charged one. | |
| 70 | +const NO_CHARGE_COLOR = '#898781' | |
| 71 | + | |
| 72 | +// The colour is never the only place the number lives -- the popup states the | |
| 73 | +// charge and the range in words, and says neither for a bike that reports | |
| 74 | +// neither. | |
| 75 | +function chargeColor(charge, dark) { | |
| 76 | + const value = parseFloat(charge) | |
| 77 | + if (!isFinite(value)) return NO_CHARGE_COLOR | |
| 78 | + | |
| 79 | + const ramp = dark ? CHARGE_RAMP.dark : CHARGE_RAMP.light | |
| 80 | + const step = Math.min(ramp.length - 1, Math.max(0, Math.floor((value / 100) * ramp.length))) | |
| 81 | + return ramp[step] | |
| 82 | +} | |
| 83 | + | |
| 84 | +// The glyphs the preview cards label their numbers with, so a popup reads the | |
| 85 | +// way the card for the same query reads. | |
| 86 | +// | |
| 87 | +// Codepoints rather than Font Awesome's own classes: the map draws inside a | |
| 88 | +// shadow root, which the document's stylesheet cannot reach, and inlining | |
| 89 | +// 138KB of Font Awesome CSS to reach a dozen icons is a poor trade. The | |
| 90 | +// @font-face is document-level and font faces are not scoped to a tree, so the | |
| 91 | +// family is available in here -- it is only the class rules that are not. | |
| 92 | +// Taken from @fortawesome/fontawesome-free 6, solid. | |
| 93 | +const ICON_GLYPHS = { | |
| 94 | + 'bicycle': '\uf206', | |
| 95 | + 'bolt-lightning': '\ue0b7', | |
| 96 | + 'square-parking': '\uf540', | |
| 97 | + 'battery-empty': '\uf244', | |
| 98 | + 'battery-quarter': '\uf243', | |
| 99 | + 'battery-half': '\uf242', | |
| 100 | + 'battery-three-quarters': '\uf241', | |
| 101 | + 'battery-full': '\uf240', | |
| 102 | + 'road': '\uf018', | |
| 103 | + 'clock': '\uf017', | |
| 104 | + 'tower-broadcast': '\uf519', | |
| 105 | + 'lungs': '\uf604', | |
| 106 | + 'ban': '\uf05e', | |
| 107 | + 'lock': '\uf023', | |
| 108 | + 'train-tram': '\uf7da', | |
| 109 | + 'train-subway': '\uf239', | |
| 110 | + 'train': '\uf238', | |
| 111 | + 'bus': '\uf207', | |
| 112 | + 'bus-simple': '\uf55e', | |
| 113 | + 'ferry': '\ue4ea', | |
| 114 | + 'mountain': '\uf6fc', | |
| 115 | + 'plane-up': '\ue22d', | |
| 116 | + 'location-dot': '\uf3c5' | |
| 117 | +} | |
| 118 | + | |
| 119 | +// The row's name, drawn as its glyph where there is one and as words where | |
| 120 | +// there is not -- a route ("71 to Watertown") or a reading ("PM2.5") is the | |
| 121 | +// data itself, not a label to be replaced by a picture. | |
| 122 | +function rowLabel(line) { | |
| 123 | + const glyph = ICON_GLYPHS[String(line.icon || '').replace(/^fa-/, '')] | |
| 124 | + | |
| 125 | + if (!glyph) return `<span class="text-gray-500">${escapeHtml(line.label)}</span>` | |
| 126 | + | |
| 127 | + // The name still reaches a screen reader, and a hover still says it. | |
| 128 | + return `<span style="font-family:'Font Awesome 6 Free';font-weight:900;font-style:normal" | |
| 129 | + class="text-gray-500" title="${escapeHtml(line.label)}" | |
| 130 | + role="img" aria-label="${escapeHtml(line.label)}">${glyph}</span>` | |
| 131 | +} | |
| 132 | + | |
| 133 | +// A glyph on the value rather than in place of the row's name: an arrival time | |
| 134 | +// is a live one or a scheduled one, which is about the number, not about which | |
| 135 | +// row it is on. | |
| 136 | +function valueIcon(line) { | |
| 137 | + const glyph = ICON_GLYPHS[String(line.value_icon || '').replace(/^fa-/, '')] | |
| 138 | + if (!glyph) return '' | |
| 139 | + | |
| 140 | + return `<span style="font-family:'Font Awesome 6 Free';font-weight:900;font-style:normal" | |
| 141 | + class="text-gray-500 mr-1" aria-hidden="true">${glyph}</span>` | |
| 142 | +} | |
| 143 | + | |
| 144 | +// Popup rows carry feed text -- a station name, a headsign -- straight from | |
| 145 | +// the source, so it is escaped rather than interpolated. | |
| 146 | +function escapeHtml(value) { | |
| 147 | + if (value === null || value === undefined) return '' | |
| 148 | + return String(value) | |
| 149 | + .replace(/&/g, '&') | |
| 150 | + .replace(/</g, '<') | |
| 151 | + .replace(/>/g, '>') | |
| 152 | + .replace(/"/g, '"') | |
| 153 | +} | |
| 154 | + | |
| 48 | 155 | // Leaflet draws circles on canvas and nothing else, but a map holding several |
| 49 | 156 | // offerings needs a third axis after fill (what kind of thing) and outline |
| 50 | 157 | // (which tint) -- so: shape says whose. |
| 282 | 389 | }); |
| 283 | 390 | this.resizeObserver.observe(this.mapElement); |
| 284 | 391 | |
| 392 | + // The charge ramp is anchored to the surface it is drawn on, so a theme | |
| 393 | + // flip restyles the bikes rather than leaving a scale reading against | |
| 394 | + // its own background. | |
| 395 | + this.unwatchTheme = onThemeChange(() => this.repaintChargeMarkers()); | |
| 396 | + | |
| 285 | 397 | // After the initial setView, so it is not mistaken for a user move. |
| 286 | 398 | this.watchForUserView(); |
| 287 | 399 | this.addResetViewControl(); |
| 401 | 513 | this.basemap.remove(); |
| 402 | 514 | this.basemap = null; |
| 403 | 515 | } |
| 516 | + if (this.unwatchTheme) { | |
| 517 | + this.unwatchTheme(); | |
| 518 | + this.unwatchTheme = null; | |
| 519 | + } | |
| 404 | 520 | // Likewise: it listens on the document, not on the map. |
| 405 | 521 | if (this.fullscreen) { |
| 406 | 522 | this.fullscreen.remove(); |
| 484 | 600 | return this.addMarkerElement(markerEl); |
| 485 | 601 | } |
| 486 | 602 | |
| 603 | + // A bike that has been ridden reports less charge; the shade is a style | |
| 604 | + // on the same canvas circle, so it does not need a new layer. | |
| 605 | + if (changedAttributes.has('charge') && existing.setStyle) { | |
| 606 | + existing.setStyle({ | |
| 607 | + fillColor: chargeColor(markerEl.getAttribute('charge'), isDarkTheme(this)) | |
| 608 | + }); | |
| 609 | + } | |
| 610 | + | |
| 487 | 611 | // The popup builds its content lazily, so a stale one only exists while |
| 488 | 612 | // it is on screen. |
| 489 | 613 | if (existing.isPopupOpen && existing.isPopupOpen()) { |
| 491 | 615 | } |
| 492 | 616 | } |
| 493 | 617 | |
| 618 | + repaintChargeMarkers() { | |
| 619 | + if (!this.map) return; | |
| 620 | + const dark = isDarkTheme(this); | |
| 621 | + | |
| 622 | + this.markerElements.forEach((markerEl, id) => { | |
| 623 | + const type = markerEl.getAttribute('type'); | |
| 624 | + if (type !== 'free_bike' && type !== 'free-bike') return; | |
| 625 | + const marker = this.markers.get(id); | |
| 626 | + if (marker && marker.setStyle) { | |
| 627 | + marker.setStyle({ fillColor: chargeColor(markerEl.getAttribute('charge'), dark) }); | |
| 628 | + } | |
| 629 | + }); | |
| 630 | + } | |
| 631 | + | |
| 494 | 632 | // Turning the part of the icon that points somewhere, rather than building |
| 495 | 633 | // a whole new icon for a heading change. False means this marker cannot be |
| 496 | 634 | // turned in place -- an image icon, or a heading appearing or disappearing, |
| 784 | 922 | } |
| 785 | 923 | |
| 786 | 924 | createFreeBikeMarker(lat, lng, markerEl) { |
| 787 | − // Smaller markers for bikes | |
| 788 | − const color = this.getMarkerColor(markerEl); | |
| 789 | − | |
| 925 | + // Smaller markers for bikes, shaded by how much charge is left in them. | |
| 926 | + // A bike is always shaded by charge -- a missing attribute is the | |
| 927 | + // unknown case, which chargeColor draws as neutral rather than as a | |
| 928 | + // value it does not have. | |
| 929 | + const color = markerEl.getAttribute('type') === 'vehicle' | |
| 930 | + ? this.getMarkerColor(markerEl) | |
| 931 | + : chargeColor(markerEl.getAttribute('charge'), isDarkTheme(this)); | |
| 932 | + | |
| 790 | 933 | return L.circleMarker([lat, lng], { |
| 791 | 934 | renderer: this.canvasRenderer, |
| 792 | 935 | radius: 4, |
| 920 | 1063 | return colorMap[tint] || null; |
| 921 | 1064 | } |
| 922 | 1065 | |
| 1066 | + // What a query currently says, as the preview cards say it: the server | |
| 1067 | + // condenses the same data into label/value lines and hands them over on the | |
| 1068 | + // marker. Coordinates stay, but at the bottom and in small type -- a popup | |
| 1069 | + // that leads with them is repeating what the marker's position already | |
| 1070 | + // said. | |
| 1071 | + summaryRows(markerEl) { | |
| 1072 | + const raw = markerEl.getAttribute('summary'); | |
| 1073 | + if (!raw) return ''; | |
| 1074 | + | |
| 1075 | + let lines; | |
| 1076 | + try { | |
| 1077 | + lines = JSON.parse(raw); | |
| 1078 | + } catch (e) { | |
| 1079 | + return ''; | |
| 1080 | + } | |
| 1081 | + if (!Array.isArray(lines) || lines.length === 0) return ''; | |
| 1082 | + | |
| 1083 | + return lines | |
| 1084 | + .map( | |
| 1085 | + (line) => ` | |
| 1086 | + <div class="flex justify-between gap-3 text-xs mb-1"> | |
| 1087 | + ${rowLabel(line)} | |
| 1088 | + <span class="font-medium text-gray-800 text-right">${ | |
| 1089 | + valueIcon(line) | |
| 1090 | + }${escapeHtml(line.value)}</span> | |
| 1091 | + </div>` | |
| 1092 | + ) | |
| 1093 | + .join(''); | |
| 1094 | + } | |
| 1095 | + | |
| 923 | 1096 | createPopupContent(markerEl) { |
| 924 | 1097 | // Extract data from marker element attributes |
| 925 | 1098 | const name = markerEl.getAttribute('name') || 'Marker'; |
| 926 | 1099 | const lat = markerEl.getAttribute('lat'); |
| 927 | 1100 | const lng = markerEl.getAttribute('lng'); |
| 928 | 1101 | const type = markerEl.getAttribute('type') || 'query'; |
| 929 | − | |
| 1102 | + const summary = this.summaryRows(markerEl); | |
| 1103 | + | |
| 930 | 1104 | // Get additional attributes based on type |
| 931 | 1105 | let content = ` |
| 932 | 1106 | <div class="p-2 min-w-48"> |
| 933 | 1107 | <h3 class="font-semibold text-sm mb-2">${name}</h3> |
| 934 | − <div class="text-xs text-gray-500 mb-2"> | |
| 935 | − <i class="fa-solid fa-location-dot mr-1"></i> | |
| 936 | − ${parseFloat(lat).toFixed(4)}, ${parseFloat(lng).toFixed(4)} | |
| 937 | − </div> | |
| 1108 | + ${summary} | |
| 938 | 1109 | `; |
| 939 | 1110 | |
| 940 | 1111 | // Only rows we actually have. This used to print the vehicle-id and |
| 960 | 1131 | row('Heading', markerEl.getAttribute('bearing')); |
| 961 | 1132 | } |
| 962 | 1133 | |
| 963 | − content += `</div>`; | |
| 1134 | + // Where it is, after what it says. | |
| 1135 | + content += ` | |
| 1136 | + <div class="text-[10px] text-gray-400 mt-2"> | |
| 1137 | + <i class="fa-solid fa-location-dot mr-1"></i> | |
| 1138 | + ${parseFloat(lat).toFixed(4)}, ${parseFloat(lng).toFixed(4)} | |
| 1139 | + </div> | |
| 1140 | + </div>`; | |
| 964 | 1141 | // Opt-in: the marker element carries phx-click, so routing the button |
| 965 | 1142 | // through markerEl.click() reuses the forwarding that already exists |
| 966 | 1143 | // rather than reaching into LiveView from here. |
modified
apps/room_sanctum/assets/js/leaflet/leaflet-marker.js
+1
−1
| 15 | 15 | } |
| 16 | 16 | |
| 17 | 17 | static get observedAttributes() { |
| 18 | − return ['lat', 'lng', 'name', 'type', 'tint', 'shape', 'bearing', 'route-type', 'aircraft-class', 'vehicle-id', 'route-id', 'selected']; | |
| 18 | + return ['lat', 'lng', 'name', 'type', 'tint', 'shape', 'bearing', 'route-type', 'aircraft-class', 'vehicle-id', 'route-id', 'selected', 'summary', 'charge']; | |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | 21 | attributeChangedCallback(name, oldValue, newValue) { |
modified
apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+66
−12
| 100 | 100 | end) |
| 101 | 101 | |
| 102 | 102 | :gbfs -> |
| 103 | + # A bike names its type by an id of the feed's own choosing -- Bay | |
| 104 | + # Wheels' e-bike is "2" -- so the types are resolved once for the list | |
| 105 | + # rather than every bike being labelled with a number. | |
| 106 | + vehicle_types = gbfs_vehicle_types(data) | |
| 107 | + | |
| 103 | 108 | data |
| 104 | − |> Enum.map(fn f -> | |
| 105 | − %{ | |
| 106 | − name: f.name, | |
| 107 | − id: f.station_id, | |
| 108 | − avail: f.num_bikes_available, | |
| 109 | − avail_elec: f.num_ebikes_available, | |
| 110 | − avail_std: f.num_bikes_available - f.num_ebikes_available, | |
| 111 | − docks_avail: f.num_docks_available, | |
| 112 | − docks_disabled: f.num_docks_disabled, | |
| 113 | − capacity: f.capacity, | |
| 114 | − ebikes_info: f.ebikes_info |> Enum.map(fn eb -> %{name: eb.displayed_number, battery_pct: eb.battery_charge_percentage, range_mi_cons: eb.range_estimate.conservative_range_miles, range_me_est: eb.range_estimate.estimated_range_miles} end) | |
| 115 | − } | |
| 109 | + |> Enum.map(fn | |
| 110 | + # An area query answers with loose bikes rather than a dock, and a | |
| 111 | + # bike has none of a station's fields -- no name, no capacity, | |
| 112 | + # nothing to dock. Told apart by what came back rather than by the | |
| 113 | + # query, since the condenser is only ever handed the answer. | |
| 114 | + %{bike_id: bike_id} = b -> | |
| 115 | + %{ | |
| 116 | + kind: :free_bike, | |
| 117 | + name: vehicle_label(vehicle_types, b) || bike_id, | |
| 118 | + id: bike_id, | |
| 119 | + lat: b.lat, | |
| 120 | + lon: b.lon, | |
| 121 | + range_m: b.current_range_meters, | |
| 122 | + fuel_pct: b.current_fuel_percent, | |
| 123 | + reserved: b.is_reserved, | |
| 124 | + disabled: b.is_disabled | |
| 125 | + } | |
| 126 | + | |
| 127 | + f -> | |
| 128 | + %{ | |
| 129 | + name: f.name, | |
| 130 | + id: f.station_id, | |
| 131 | + avail: f.num_bikes_available, | |
| 132 | + avail_elec: f.num_ebikes_available, | |
| 133 | + avail_std: f.num_bikes_available - f.num_ebikes_available, | |
| 134 | + docks_avail: f.num_docks_available, | |
| 135 | + docks_disabled: f.num_docks_disabled, | |
| 136 | + capacity: f.capacity, | |
| 137 | + ebikes_info: | |
| 138 | + f.ebikes_info | |
| 139 | + |> Enum.map(fn eb -> | |
| 140 | + %{ | |
| 141 | + name: eb.displayed_number, | |
| 142 | + battery_pct: eb.battery_charge_percentage, | |
| 143 | + range_mi_cons: eb.range_estimate.conservative_range_miles, | |
| 144 | + range_me_est: eb.range_estimate.estimated_range_miles | |
| 145 | + } | |
| 146 | + end) | |
| 147 | + } | |
| 116 | 148 | end) |
| 117 | 149 | |
| 118 | 150 | :tidal -> |
| 251 | 283 | } |
| 252 | 284 | } |
| 253 | 285 | end |
| 286 | + | |
| 287 | + # The vehicle types behind a list of bikes, or an empty map for a list that | |
| 288 | + # holds none -- a station answer, or a source whose vehicle_types.json has | |
| 289 | + # not been read yet. | |
| 290 | + defp gbfs_vehicle_types(data) do | |
| 291 | + data | |
| 292 | + |> Enum.flat_map(fn | |
| 293 | + %{bike_id: _, source_id: source_id} when not is_nil(source_id) -> [source_id] | |
| 294 | + _ -> [] | |
| 295 | + end) | |
| 296 | + |> Enum.uniq() | |
| 297 | + |> Enum.reduce(%{}, fn source_id, acc -> | |
| 298 | + Map.merge(acc, RoomSanctum.Storage.gbfs_vehicle_types(source_id)) | |
| 299 | + end) | |
| 300 | + end | |
| 301 | + | |
| 302 | + defp vehicle_label(vehicle_types, bike) do | |
| 303 | + vehicle_types | |
| 304 | + |> Map.get(Map.get(bike, :vehicle_type_id)) | |
| 305 | + |> RoomSanctum.Storage.GBFS.V1.VehicleTypes.label() | |
| 306 | + end | |
| 307 | + | |
| 254 | 308 | end |
modified
apps/room_sanctum/lib/room_sanctum/configuration/queries/gbfs.ex
+50
−2
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum/storage.ex
+83
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum/storage/gbfs/v1/vehicle_types.ex
+38
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+195
−13
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/form_component.html.heex
+24
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+13
−2
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+4
−1
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/show.ex
+47
−1
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/show.html.heex
+7
−0
Click to load diff…
added
apps/room_sanctum/lib/room_sanctum_web/live/helpers/map_data.ex
+362
−0
Click to load diff…
modified
apps/room_sanctum/lib/room_sanctum_web/live/live_preview.ex
+39
−1
Click to load diff…
added
apps/room_sanctum/test/room_sanctum/gbfs_query_test.exs
+57
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum/map_data_test.exs
+185
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum_web/components/bike_charge_test.exs
+99
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum_web/components/dock_popup_test.exs
+96
−0
Click to load diff…
added
apps/room_sanctum/test/room_sanctum_web/components/gbfs_preview_test.exs
+84
−0
Click to load diff…
modified
apps/room_sanctum/test/room_sanctum_web/components/query_placement_test.exs
+39
−0
Click to load diff…
Parents: b060813