diff --git a/assets/src/components/detourListPage.tsx b/assets/src/components/detourListPage.tsx index 0193a2513..fa605fecc 100644 --- a/assets/src/components/detourListPage.tsx +++ b/assets/src/components/detourListPage.tsx @@ -1,7 +1,11 @@ import React, { useCallback, useState } from "react" -import { DetoursTable, DetourStatus } from "./detoursTable" +import { + DetoursTable, + DetourStatus, + timestampLabelFromStatus, +} from "./detoursTable" import userInTestGroup, { TestGroups } from "../userInTestGroup" -import { Button } from "react-bootstrap" +import { Button, Table } from "react-bootstrap" import { GlobeAmericas, LockFill, @@ -16,6 +20,9 @@ import { isErr, isOk } from "../util/result" import { isValidSnapshot } from "../util/isValidSnapshot" import { createDetourMachine } from "../models/createDetourMachine" import { joinClasses } from "../helpers/dom" +import { RoutePill } from "./routePill" +import { timeAgoLabelForDates } from "../util/dateTime" +import { useCurrentTime } from "../hooks/useCurrentTime" export const DetourListPage = () => { const [showDetourModal, setShowDetourModal] = useState(false) @@ -55,6 +62,8 @@ export const DetourListPage = () => { setShowDetourModal(false) } + const epochNow = useCurrentTime() + return (
{userInTestGroup(TestGroups.DetoursPilot) && ( @@ -75,12 +84,53 @@ export const DetourListPage = () => { visibility="All Skate users" classNames={["d-flex"]} /> - + + + + + + + + + + {detours.active + ? detours.active.map((detour, index) => ( + onOpenDetour(detour.details.id)} + > + + + + + )) + : null} + +
Route and direction + Starting Intersection + + {timestampLabelFromStatus(DetourStatus.Active)} +
+
+ +
+
+ {detour.details.name} +
+
+ {detour.details.direction} +
+
+
+
+ {detour.details.intersection} + + {timeAgoLabelForDates(detour.activatedAt, epochNow)} +
{userInTestGroup(TestGroups.DetoursPilot) && ( <> { +export const useCurrentTime = (): Date => { const [currentTime, setCurrentTime] = useState(now()) useInterval(() => setCurrentTime(now()), 1000) return currentTime diff --git a/assets/src/models/detoursList.ts b/assets/src/models/detoursList.ts index 8df56eb96..1d004ea7a 100644 --- a/assets/src/models/detoursList.ts +++ b/assets/src/models/detoursList.ts @@ -1,4 +1,13 @@ -import { array, Infer, nullable, number, string, type } from "superstruct" +import { + array, + coerce, + date, + Infer, + nullable, + number, + string, + type, +} from "superstruct" export type DetourId = number export interface SimpleDetour { @@ -10,6 +19,11 @@ export interface SimpleDetour { updatedAt: number } +export interface ActivatedDetour { + activatedAt: Date + details: SimpleDetour +} + export const detourId = number() export const SimpleDetourData = type({ id: detourId, @@ -20,6 +34,11 @@ export const SimpleDetourData = type({ updated_at: number(), }) +export const ActivatedDetourData = type({ + activated_at: coerce(date(), string(), (dateStr) => new Date(dateStr)), + details: SimpleDetourData, +}) + export type SimpleDetourData = Infer<typeof SimpleDetourData> export const simpleDetourFromData = ( @@ -34,13 +53,13 @@ export const simpleDetourFromData = ( }) export interface GroupedSimpleDetours { - active?: SimpleDetour[] + active?: ActivatedDetour[] draft?: SimpleDetour[] past?: SimpleDetour[] } export const GroupedDetoursData = type({ - active: nullable(array(SimpleDetourData)), + active: nullable(array(ActivatedDetourData)), draft: nullable(array(SimpleDetourData)), past: nullable(array(SimpleDetourData)), }) @@ -50,7 +69,10 @@ export type GroupedDetoursData = Infer<typeof GroupedDetoursData> export const groupedDetoursFromData = ( groupedDetours: GroupedDetoursData ): GroupedSimpleDetours => ({ - active: groupedDetours.active?.map((detour) => simpleDetourFromData(detour)), + active: groupedDetours.active?.map((detour) => ({ + details: simpleDetourFromData(detour.details), + activatedAt: detour.activated_at, + })), draft: groupedDetours.draft?.map((detour) => simpleDetourFromData(detour)), past: groupedDetours.past?.map((detour) => simpleDetourFromData(detour)), }) diff --git a/assets/src/util/dateTime.ts b/assets/src/util/dateTime.ts index 305331ede..8d5582fc8 100644 --- a/assets/src/util/dateTime.ts +++ b/assets/src/util/dateTime.ts @@ -94,13 +94,27 @@ export const secondsAgoLabel = ( epochTime: number ): string => `${epochNowInSeconds - epochTime}s ago` +const second = 1000 +const minute = second * 60 +const hour = minute * 60 + export const timeAgoLabel = ( epochNowInSeconds: number, epochTime: number ): string => { const duration = epochNowInSeconds - epochTime - const diffHours = Math.floor(duration / 3_600) - const diffMinutes = Math.floor((duration % 3_600) / 60) - return diffHours >= 1 ? `${diffHours} hours ago` : `${diffMinutes} min ago` + if (duration > 1 * hour) { + return `${Math.floor(duration / hour)} hours ago` + } + + if (duration > 1 * minute) { + return `${Math.floor(duration / minute)} min ago` + } + + return "Just now" +} + +export const timeAgoLabelForDates = (start: Date, end: Date) => { + return timeAgoLabel(end.valueOf() / second, start.valueOf() / second) } diff --git a/assets/tests/components/detours/__snapshots__/detourListPage.test.tsx.snap b/assets/tests/components/detours/__snapshots__/detourListPage.test.tsx.snap index 1610141e0..72509ae3f 100644 --- a/assets/tests/components/detours/__snapshots__/detourListPage.test.tsx.snap +++ b/assets/tests/components/detours/__snapshots__/detourListPage.test.tsx.snap @@ -121,7 +121,7 @@ exports[`DetourListPage renders detour list page for dispatchers 1`] = ` <td class="align-middle p-3 u-hide-for-mobile" > - 26 hours ago + 1 min ago </td> </tr> <tr> @@ -160,7 +160,7 @@ exports[`DetourListPage renders detour list page for dispatchers 1`] = ` <td class="align-middle p-3 u-hide-for-mobile" > - 29 hours ago + 1 min ago </td> </tr> </tbody> @@ -349,7 +349,7 @@ exports[`DetourListPage renders detour list page for dispatchers 1`] = ` <td class="align-middle p-3 u-hide-for-mobile" > - 26 hours ago + 1 min ago </td> </tr> <tr> @@ -388,7 +388,7 @@ exports[`DetourListPage renders detour list page for dispatchers 1`] = ` <td class="align-middle p-3 u-hide-for-mobile" > - 26 hours ago + 1 min ago </td> </tr> </tbody> @@ -492,7 +492,7 @@ exports[`DetourListPage renders limited detour list page for non-dispatchers 1`] <td class="align-middle p-3 u-hide-for-mobile" > - 26 hours ago + 1 min ago </td> </tr> <tr> @@ -531,7 +531,7 @@ exports[`DetourListPage renders limited detour list page for non-dispatchers 1`] <td class="align-middle p-3 u-hide-for-mobile" > - 29 hours ago + 1 min ago </td> </tr> </tbody> diff --git a/assets/tests/components/detours/__snapshots__/detoursListPage.openDetour.test.tsx.snap b/assets/tests/components/detours/__snapshots__/detoursListPage.openDetour.test.tsx.snap index 75670605e..381d5cef3 100644 --- a/assets/tests/components/detours/__snapshots__/detoursListPage.openDetour.test.tsx.snap +++ b/assets/tests/components/detours/__snapshots__/detoursListPage.openDetour.test.tsx.snap @@ -124,7 +124,7 @@ exports[`Detours Page: Open a Detour renders detour details in an open drawer on <td class="align-middle p-3 u-hide-for-mobile" > - 26 hours ago + Just now </td> </tr> <tr> @@ -163,7 +163,7 @@ exports[`Detours Page: Open a Detour renders detour details in an open drawer on <td class="align-middle p-3 u-hide-for-mobile" > - 26 hours ago + Just now </td> </tr> </tbody> @@ -352,7 +352,7 @@ exports[`Detours Page: Open a Detour renders detour details in an open drawer on <td class="align-middle p-3 u-hide-for-mobile" > - 26 hours ago + 1 min ago </td> </tr> </tbody> @@ -971,7 +971,7 @@ exports[`Detours Page: Open a Detour renders detour details modal to match mocke <td class="align-middle p-3 u-hide-for-mobile" > - 26 hours ago + Just now </td> </tr> <tr> @@ -1010,7 +1010,7 @@ exports[`Detours Page: Open a Detour renders detour details modal to match mocke <td class="align-middle p-3 u-hide-for-mobile" > - 26 hours ago + Just now </td> </tr> </tbody> @@ -1199,7 +1199,7 @@ exports[`Detours Page: Open a Detour renders detour details modal to match mocke <td class="align-middle p-3 u-hide-for-mobile" > - 26 hours ago + 1 min ago </td> </tr> </tbody> @@ -1228,7 +1228,7 @@ exports[`Detours Page: Open a Detour renders detour details modal to match mocke > Last edited </strong> - 26 hours ago + 1 min ago </span> <span class="l-diversion-page__header-details" diff --git a/assets/tests/components/detours/detourListPage.test.tsx b/assets/tests/components/detours/detourListPage.test.tsx index d0774286b..3f1a37785 100644 --- a/assets/tests/components/detours/detourListPage.test.tsx +++ b/assets/tests/components/detours/detourListPage.test.tsx @@ -35,20 +35,26 @@ describe("DetourListPage", () => { Ok({ active: [ { - id: 1, - route: "1", - direction: "Inbound", - name: "Headsign A", - intersection: "Street A & Avenue B", - updatedAt: 1724866392, + details: { + id: 1, + route: "1", + direction: "Inbound", + name: "Headsign A", + intersection: "Street A & Avenue B", + updatedAt: 1724866392, + }, + activatedAt: new Date(1724866392000), }, { - id: 8, - route: "2", - direction: "Outbound", - name: "Headsign B", - intersection: "Street C & Avenue D", - updatedAt: 1724856392, + details: { + id: 8, + route: "2", + direction: "Outbound", + name: "Headsign B", + intersection: "Street C & Avenue D", + updatedAt: 1724856392, + }, + activatedAt: new Date(1724856392000), }, ], draft: undefined, @@ -93,20 +99,26 @@ describe("DetourListPage", () => { Ok({ active: [ { - id: 1, - route: "1", - direction: "Inbound", - name: "Headsign A", - intersection: "Street A & Avenue B", - updatedAt: 1724866392, + details: { + id: 1, + route: "1", + direction: "Inbound", + name: "Headsign A", + intersection: "Street A & Avenue B", + updatedAt: 1724866392, + }, + activatedAt: new Date(1724866392000), }, { - id: 8, - route: "2", - direction: "Outbound", - name: "Headsign B", - intersection: "Street C & Avenue D", - updatedAt: 1724856392, + details: { + id: 8, + route: "2", + direction: "Outbound", + name: "Headsign B", + intersection: "Street C & Avenue D", + updatedAt: 1724856392, + }, + activatedAt: new Date(1724856392000), }, ], draft: undefined, diff --git a/assets/tests/factories/detourListFactory.ts b/assets/tests/factories/detourListFactory.ts index d90ba8315..08dbaedd0 100644 --- a/assets/tests/factories/detourListFactory.ts +++ b/assets/tests/factories/detourListFactory.ts @@ -1,5 +1,6 @@ import { Factory } from "fishery" import { + ActivatedDetour, GroupedSimpleDetours, SimpleDetour, } from "../../src/models/detoursList" @@ -7,8 +8,8 @@ import { export const detourListFactory = Factory.define<GroupedSimpleDetours>(() => { return { active: [ - simpleDetourFactory.build(), - simpleDetourFactory.build({ direction: "Outbound" }), + activeDetourFactory.build(), + activeDetourFactory.build({ details: { direction: "Outbound" } }), ], draft: undefined, past: [simpleDetourFactory.build({ name: "Headsign Z" })], @@ -23,3 +24,8 @@ const simpleDetourFactory = Factory.define<SimpleDetour>(({ sequence }) => ({ intersection: `Street A${sequence} & Avenue B${sequence}`, updatedAt: 1724866392, })) + +const activeDetourFactory = Factory.define<ActivatedDetour>(() => ({ + activatedAt: new Date(), + details: simpleDetourFactory.build(), +})) diff --git a/lib/skate/detours/detour.ex b/lib/skate/detours/detour.ex index 53581c82c..d7758dce5 100644 --- a/lib/skate/detours/detour.ex +++ b/lib/skate/detours/detour.ex @@ -30,6 +30,59 @@ defmodule Skate.Detours.Detour do :author_id, :status ] + + def from( + status, + %{ + state: %{ + "context" => %{ + "route" => %{"name" => route_name, "directionNames" => direction_names}, + "routePattern" => %{"headsign" => headsign, "directionId" => direction_id}, + "nearestIntersection" => nearest_intersection + } + } + } = db_detour + ) do + direction = Map.get(direction_names, Integer.to_string(direction_id)) + + %__MODULE__{ + id: db_detour.id, + route: route_name, + direction: direction, + name: headsign, + intersection: nearest_intersection, + updated_at: timestamp_to_unix(db_detour.updated_at), + author_id: db_detour.author_id, + status: status + } + end + + def from(_status, _attrs), do: nil + + # Converts the db timestamp to unix + defp timestamp_to_unix(db_date) do + db_date + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + end + end + + defmodule ActivatedDetourDetails do + @moduledoc """ + Extended information for active detours + """ + + @type t :: %__MODULE__{ + activated_at: DateTime.t(), + details: Detailed.t() + } + + @derive Jason.Encoder + + defstruct [ + :activated_at, + :details + ] end defmodule WithState do diff --git a/lib/skate/detours/detours.ex b/lib/skate/detours/detours.ex index 53e0b2fc1..6d12bb2d1 100644 --- a/lib/skate/detours/detours.ex +++ b/lib/skate/detours/detours.ex @@ -4,6 +4,7 @@ defmodule Skate.Detours.Detours do """ import Ecto.Query, warn: false + alias Skate.Detours.Detour.ActivatedDetourDetails alias Skate.Repo alias Skate.Detours.Db.Detour alias Skate.Detours.SnapshotSerde @@ -50,7 +51,10 @@ defmodule Skate.Detours.Detours do list_detours() |> Enum.map(&db_detour_to_detour/1) |> Enum.filter(& &1) - |> Enum.group_by(fn detour -> detour.status end) + |> Enum.group_by(fn + %ActivatedDetourDetails{details: %{status: status}} -> status + %{status: status} -> status + end) %{ active: Map.get(detours, :active), @@ -61,29 +65,9 @@ defmodule Skate.Detours.Detours do end @spec db_detour_to_detour(Detour.t()) :: DetailedDetour.t() | nil - def db_detour_to_detour( - %{ - state: %{ - "context" => %{ - "route" => %{"name" => route_name, "directionNames" => direction_names}, - "routePattern" => %{"headsign" => headsign, "directionId" => direction_id}, - "nearestIntersection" => nearest_intersection - } - } - } = db_detour - ) do - direction = Map.get(direction_names, Integer.to_string(direction_id)) - - %DetailedDetour{ - id: db_detour.id, - route: route_name, - direction: direction, - name: headsign, - intersection: nearest_intersection, - updated_at: timestamp_to_unix(db_detour.updated_at), - author_id: db_detour.author_id, - status: categorize_detour(db_detour) - } + @spec db_detour_to_detour(status :: detour_type(), Detour.t()) :: DetailedDetour.t() | nil + def db_detour_to_detour(%{} = db_detour) do + db_detour_to_detour(categorize_detour(db_detour), db_detour) end def db_detour_to_detour(invalid_detour) do @@ -94,6 +78,34 @@ defmodule Skate.Detours.Detours do nil end + def db_detour_to_detour( + :active, + %Detour{activated_at: activated_at} = db_detour + ) do + details = DetailedDetour.from(:active, db_detour) + + details && + %ActivatedDetourDetails{ + activated_at: activated_at, + details: details + } + end + + def db_detour_to_detour( + status, + %{} = db_detour + ) do + DetailedDetour.from(status, db_detour) + end + + def db_detour_to_detour(state, invalid_detour) do + Sentry.capture_message("Detour error: the detour has an outdated schema", + extra: %{error: invalid_detour, state: state} + ) + + nil + end + @type detour_type :: :active | :draft | :past @doc """ diff --git a/test/skate_web/controllers/detours_controller_test.exs b/test/skate_web/controllers/detours_controller_test.exs index bc2097dba..84363d12d 100644 --- a/test/skate_web/controllers/detours_controller_test.exs +++ b/test/skate_web/controllers/detours_controller_test.exs @@ -146,24 +146,28 @@ defmodule SkateWeb.DetoursControllerTest do defp populate_db_and_get_user(conn) do # Active detour put(conn, "/api/detours/update_snapshot", %{ - "snapshot" => %{ - "context" => %{ - "route" => %{ - "name" => "23", - "directionNames" => %{ - "0" => "Outbound", - "1" => "Inbound" - } - }, - "routePattern" => %{ - "headsign" => "Headsign", - "directionId" => 0 + "snapshot" => + activated( + %{ + "context" => %{ + "route" => %{ + "name" => "23", + "directionNames" => %{ + "0" => "Outbound", + "1" => "Inbound" + } + }, + "routePattern" => %{ + "headsign" => "Headsign", + "directionId" => 0 + }, + "nearestIntersection" => "Street A & Avenue B", + "uuid" => 1 + }, + "value" => %{"Detour Drawing" => %{"Active" => "Reviewing"}} }, - "nearestIntersection" => "Street A & Avenue B", - "uuid" => 1 - }, - "value" => %{"Detour Drawing" => %{"Active" => "Reviewing"}} - } + ~U[2024-01-01 13:00:00.000000Z] + ) }) # Past detour @@ -364,13 +368,15 @@ defmodule SkateWeb.DetoursControllerTest do "data" => %{ "active" => [ %{ - "author_id" => ^author_id, - "direction" => "Outbound", - "intersection" => "Street A & Avenue B", - "name" => "Headsign", - "route" => "23", - "status" => "active", - "updated_at" => _ + "details" => %{ + "author_id" => ^author_id, + "direction" => "Outbound", + "intersection" => "Street A & Avenue B", + "name" => "Headsign", + "route" => "23", + "status" => "active", + "updated_at" => _ + } } ], "draft" => [ @@ -429,15 +435,7 @@ defmodule SkateWeb.DetoursControllerTest do assert %{ "data" => %{ "active" => [ - %{ - "author_id" => ^current_user_id, - "direction" => "Outbound", - "intersection" => "Street A & Avenue B", - "name" => "Headsign", - "route" => "23", - "status" => "active", - "updated_at" => _ - } + _ ], "draft" => [ %{ @@ -514,13 +512,16 @@ defmodule SkateWeb.DetoursControllerTest do "data" => %{ "active" => [ %{ - "author_id" => ^author_id, - "direction" => "Outbound", - "intersection" => "Street A & Avenue B", - "name" => "Headsign", - "route" => "23", - "status" => "active", - "updated_at" => _ + "activated_at" => "2024-01-01T13:00:00.000000Z", + "details" => %{ + "author_id" => ^author_id, + "direction" => "Outbound", + "intersection" => "Street A & Avenue B", + "name" => "Headsign", + "route" => "23", + "status" => "active", + "updated_at" => _ + } } ], "draft" => [