Skip to content

Commit

Permalink
feat: View of completed operator sign-ins (#51)
Browse files Browse the repository at this point in the history
  • Loading branch information
mathcolo authored Jul 22, 2024
1 parent ffa6365 commit 9c6ac8c
Show file tree
Hide file tree
Showing 22 changed files with 534 additions and 4 deletions.
3 changes: 2 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import Config
config :orbit,
ecto_repos: [Orbit.Repo],
generators: [timestamp_type: :utc_datetime],
force_https?: true
force_https?: true,
timezone: "America/New_York"

# Endpoint config
config :orbit, OrbitWeb.Endpoint,
Expand Down
4 changes: 4 additions & 0 deletions js/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ export const fetch = window.fetch;
export const reload = () => {
window.location.reload();
};

export const back = () => {
history.go(-1);
};
5 changes: 5 additions & 0 deletions js/components/app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Help } from "./help";
import { Home } from "./home";
import { List } from "./operatorSignIn/list";
import { ReactElement } from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";

Expand All @@ -8,6 +9,10 @@ const router = createBrowserRouter([
path: "/",
element: <Home />,
},
{
path: "/list",
element: <List line="blue" />,
},
{
path: "/help",
element: <Help />,
Expand Down
8 changes: 8 additions & 0 deletions js/components/home.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { OperatorSignInModal } from "./operatorSignIn/operatorSignInModal";
import { ReactElement } from "react";
import { Link } from "react-router-dom";

export const Home = (): ReactElement => {
return (
Expand All @@ -9,6 +10,13 @@ export const Home = (): ReactElement => {
<span className="text-mbta-orange">O</span>
<span className="text-mbta-red">r</span>
<span className="text-mbta-blue">b</span>it
<div>
<Link to="/list">
<button className="bg-mbta-blue text-gray-100 rounded-md p-2 text-sm m-5">
Sign-in history
</button>
</Link>
</div>
</div>
<OperatorSignInModal />
</>
Expand Down
57 changes: 57 additions & 0 deletions js/components/operatorSignIn/list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { back } from "../../browser";
import { lookupDisplayName, useEmployees } from "../../hooks/useEmployees";
import { useSignins } from "../../hooks/useSignIns";
import { HeavyRailLine } from "../../types";
import { DateTime } from "luxon";
import { ReactElement } from "react";

export const List = ({ line }: { line: HeavyRailLine }): ReactElement => {
const employees = useEmployees();
const signIns = useSignins(line);

if (signIns.status === "loading" || employees.status === "loading") {
return <>Loading...</>;
} else if (signIns.status === "error") {
return <>Error retrieving signins: {signIns.error}</>;
} else if (employees.status === "error") {
return <>Error retrieving employees: {employees.error}</>;
}

return (
<div className="m-2">
<button
className="bg-mbta-blue text-gray-100 rounded-md p-2 text-sm m-5"
onClick={() => {
back();
}}
>
Back
</button>
<u>Today&apos;s sign-ins</u>
<table>
<thead>
<tr>
<th className="border">Name</th>
<th className="border">Badge</th>
<th className="border">Time</th>
<th className="border">Official</th>
</tr>
</thead>
<tbody>
{signIns.result.map((si, idx) => (
<tr key={idx}>
<td className="border">
{lookupDisplayName(si.signed_in_employee, employees.result)}
</td>
<td className="border">{si.signed_in_employee}</td>
<td className="border">
{si.signed_in_at.toLocaleString(DateTime.TIME_SIMPLE)}
</td>
<td className="border">{si.signed_in_by_user}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
16 changes: 15 additions & 1 deletion js/hooks/useEmployees.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useApiResult } from "../api";
import { Employee, EmployeeList } from "../models/employee";
import {
displayName,
Employee,
EmployeeList,
fallbackDisplayName,
} from "../models/employee";

const EMPLOYEES_API_PATH = "/api/employees";

Expand All @@ -16,6 +21,15 @@ export const useEmployees = () => {
});
};

export const lookupDisplayName = (badge: string, employees: Employee[]) => {
const employee = findEmployeeByBadge(employees, badge);
if (employee === undefined) {
return fallbackDisplayName(badge);
}

return displayName(employee);
};

export const findEmployeeByBadge = (employees: Employee[], badge: string) => {
return employees.find((employee) => employee.badge === badge);
};
Expand Down
22 changes: 22 additions & 0 deletions js/hooks/useSignIns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useApiResult } from "../api";
import { SignIn, SignInList } from "../models/signin";
import { HeavyRailLine } from "../types";
import { DateTime } from "luxon";

const SIGN_INS_API_PATH = "/api/signin";

const parse = (list: SignInList) =>
list.map((si: SignIn) => ({
...si,
signed_in_at: DateTime.fromISO(si.signed_in_at, {
zone: "America/New_York",
}),
}));

export const useSignins = (line: HeavyRailLine) => {
return useApiResult({
RawData: SignInList,
url: `${SIGN_INS_API_PATH}?line=${line}`,
parser: parse,
});
};
8 changes: 8 additions & 0 deletions js/models/employee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ export const Employee = z.object({
badge_serials: z.array(z.string()),
});

export const displayName = (emp: Employee) => {
return `${emp.preferred_first ?? emp.first_name} ${emp.last_name}`;
};

export const fallbackDisplayName = (badge: string) => {
return `Operator #${badge}`;
};

export type Employee = z.infer<typeof Employee>;

export const EmployeeList = z.array(Employee);
Expand Down
13 changes: 13 additions & 0 deletions js/models/signin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { z } from "zod";

export const SignIn = z.object({
rail_line: z.enum(["blue", "orange", "red"]),
signed_in_at: z.string(),
signed_in_by_user: z.string(),
signed_in_employee: z.string(),
});

export type SignIn = z.infer<typeof SignIn>;

export const SignInList = z.array(SignIn);
export type SignInList = z.infer<typeof SignInList>;
7 changes: 6 additions & 1 deletion js/test/components/home.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { Home } from "../../components/home";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";

jest.mock("../../components/operatorSignIn/operatorSignInModal", () => ({
OperatorSignInModal: () => <div>Mock modal</div>,
}));

describe("home", () => {
test("loads orbit placeholder", () => {
const view = render(<Home />);
const view = render(
<MemoryRouter>
<Home />
</MemoryRouter>,
);
expect(view.getByText(/🪐/)).toBeInTheDocument();
});
});
45 changes: 45 additions & 0 deletions js/test/components/operatorSignIn/list.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { List } from "../../../components/operatorSignIn/list";
import { displayName } from "../../../models/employee";
import { employeeFactory } from "../../helpers/factory";
import { render } from "@testing-library/react";
import { DateTime } from "luxon";

const EMPLOYEES = [employeeFactory.build()];
jest.mock("../../../hooks/useEmployees", () => ({
useEmployees: jest.fn().mockImplementation(() => ({
status: "ok",
result: EMPLOYEES,
})),
findEmployeeByBadge: jest.fn(() => EMPLOYEES[0]),
findEmployeeByBadgeSerial: jest.fn(() => EMPLOYEES[0]),
lookupDisplayName: jest.fn(() => displayName(EMPLOYEES[0])),
}));

jest.mock("../../../hooks/useSignIns", () => ({
useSignins: jest.fn().mockImplementation(() => ({
status: "ok",
result: [
{
rail_line: "blue",
signed_in_at: DateTime.fromISO("2024-07-22T12:45:52.000-04:00", {
zone: "America/New_York",
}),
signed_in_by_user: "[email protected]",
signed_in_employee: EMPLOYEES[0].badge,
},
],
})),
}));

describe("List", () => {
test("shows a sign-in", () => {
const view = render(<List line="blue" />);
expect(view.getByText("12:45 PM")).toBeInTheDocument();
expect(
view.getByText(
`${EMPLOYEES[0].preferred_first} ${EMPLOYEES[0].last_name}`,
),
).toBeInTheDocument();
expect(view.getByText("[email protected]")).toBeInTheDocument();
});
});
14 changes: 14 additions & 0 deletions js/test/hooks/useEmployees.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fetch } from "../../browser";
import {
findEmployeeByBadge,
findEmployeeByBadgeSerial,
lookupDisplayName,
useEmployees,
} from "../../hooks/useEmployees";
import { employeeFactory } from "../helpers/factory";
Expand Down Expand Up @@ -39,6 +40,19 @@ describe("useEmployees", () => {
});
});

describe("lookupDisplayName", () => {
test("can retrieve/compute the display name for an employee", () => {
expect(lookupDisplayName(TEST_PARSED[0].badge, TEST_PARSED)).toBe(
"Preferredy Lasty",
);
});
test("can use the fallback string for an unknown operator", () => {
expect(lookupDisplayName("49203492152352341", TEST_PARSED)).toBe(
"Operator #49203492152352341",
);
});
});

describe("findEmployeeByBadge", () => {
test("finds an employee in an array by badge number", () => {
const employeeBadge = TEST_PARSED[0].badge;
Expand Down
50 changes: 50 additions & 0 deletions js/test/hooks/useSignins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { fetch } from "../../browser";
import { useSignins } from "../../hooks/useSignIns";
import { PromiseWithResolvers } from "../helpers/promiseWithResolvers";
import { renderHook, waitFor } from "@testing-library/react";
import { DateTime } from "luxon";

jest.mock("../../browser", () => ({
fetch: jest.fn(),
}));

const TEST_DATA = {
data: [
{
rail_line: "blue",
signed_in_at: "2024-07-22T16:42:32Z",
signed_in_by_user: "[email protected]",
signed_in_employee: "123",
},
],
};

const TEST_PARSED = [
{
...TEST_DATA.data[0],
signed_in_at: DateTime.fromISO(TEST_DATA.data[0].signed_in_at, {
zone: "America/New_York",
}),
},
];

describe("useSignins", () => {
test("parses api response", async () => {
const { promise, resolve } = PromiseWithResolvers<Response>();
jest.mocked(fetch).mockReturnValue(promise);

const { result } = renderHook(useSignins);

resolve({
status: 200,
json: () =>
new Promise((resolve) => {
resolve(TEST_DATA);
}),
} as Response);

await waitFor(() => {
expect(result.current).toEqual({ status: "ok", result: TEST_PARSED });
});
});
});
1 change: 1 addition & 0 deletions js/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type HeavyRailLine = "blue" | "orange" | "red";
2 changes: 1 addition & 1 deletion lib/orbit_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ defmodule OrbitWeb.AuthController do
else
conn
|> put_status(:unauthorized)
|> json("Unauthorized")
|> text("Unauthorized")
|> halt()
end
end
Expand Down
39 changes: 39 additions & 0 deletions lib/orbit_web/controllers/sign_in_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,45 @@ defmodule OrbitWeb.SignInController do

alias Orbit.Repo

@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
def index(conn, params) do
line = params["line"] || "blue"

service_date =
if params["service_date"],
do: Date.from_iso8601!(params["service_date"]),
else: Util.Time.service_date_for_timestamp(Util.Time.current_time())

{start_datetime, end_datetime} = Util.Time.service_date_boundaries(service_date)

rail_line = String.to_existing_atom(line)

json(
conn,
%{
data:
Repo.all(
from(si in OperatorSignIn,
where:
^start_datetime <= si.signed_in_at and
si.signed_in_at < ^end_datetime and
si.rail_line == ^rail_line,
preload: [:signed_in_employee, :signed_in_by_user],
order_by: [desc: :signed_in_at]
)
)
|> Enum.map(fn si ->
%{
rail_line: si.rail_line,
signed_in_at: DateTime.to_iso8601(si.signed_in_at),
signed_in_by_user: si.signed_in_by_user.email,
signed_in_employee: si.signed_in_employee.badge_number
}
end)
}
)
end

@spec submit(Plug.Conn.t(), map()) :: Plug.Conn.t()
def submit(conn, %{
"signed_in_employee_badge" => signed_in_employee_badge,
Expand Down
Loading

0 comments on commit 9c6ac8c

Please sign in to comment.