diff --git a/datastores/run_data.ts b/datastores/run_data.ts index ee2892a..1361d27 100644 --- a/datastores/run_data.ts +++ b/datastores/run_data.ts @@ -1,4 +1,7 @@ import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts"; +import { SlackAPIClient } from "deno-slack-sdk/types.ts"; +import { DatastoreItem } from "deno-slack-api/types.ts"; +import { DatastoreQueryResponse } from "deno-slack-api/typed-method-types/apps.ts"; export const RUN_DATASTORE = "running_datastore"; @@ -21,4 +24,44 @@ const RunningDatastore = DefineDatastore({ }, }); +/** + * Returns the complete collection from the datastore for an expression + * + * @param client the client to interact with the Slack API + * @param expressions optional filters and attributes to refine a query + * + * @returns ok if the query completed successfully + * @returns items a list of responses from the datastore + * @returns error the description of any server error + */ +export async function queryRunningDatastore( + client: SlackAPIClient, + expressions?: object, +): Promise<{ + ok: boolean; + items: DatastoreItem[]; + error?: string; +}> { + const items: DatastoreItem[] = []; + let cursor = undefined; + + do { + const runs: DatastoreQueryResponse = + await client.apps.datastore.query({ + datastore: RUN_DATASTORE, + cursor, + ...expressions, + }); + + if (!runs.ok) { + return { ok: false, items, error: runs.error }; + } + + cursor = runs.response_metadata?.next_cursor; + items.push(...runs.items); + } while (cursor); + + return { ok: true, items }; +} + export default RunningDatastore; diff --git a/functions/collect_runner_stats.ts b/functions/collect_runner_stats.ts index f4dad91..4a36a9c 100644 --- a/functions/collect_runner_stats.ts +++ b/functions/collect_runner_stats.ts @@ -1,5 +1,5 @@ import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts"; -import RunningDatastore, { RUN_DATASTORE } from "../datastores/run_data.ts"; +import { queryRunningDatastore } from "../datastores/run_data.ts"; import { RunnerStatsType } from "../types/runner_stats.ts"; export const CollectRunnerStatsFunction = DefineFunction({ @@ -16,6 +16,7 @@ export const CollectRunnerStatsFunction = DefineFunction({ runner_stats: { type: Schema.types.array, items: { type: RunnerStatsType }, + title: "Runner stats", description: "Weekly and all-time total distances for runners", }, }, @@ -24,28 +25,27 @@ export const CollectRunnerStatsFunction = DefineFunction({ }); export default SlackFunction(CollectRunnerStatsFunction, async ({ client }) => { - // Query the datastore for all the data we collected - const runs = await client.apps.datastore.query< - typeof RunningDatastore.definition - >({ datastore: RUN_DATASTORE }); - - if (!runs.ok) { - return { error: `Failed to retrieve past runs: ${runs.error}` }; - } - const runners = new Map(); - const startOfLastWeek = new Date(); - startOfLastWeek.setDate(startOfLastWeek.getDate() - 6); + const today = new Date(Date.now()); + const startOfLastWeek = new Date( + new Date(Date.now()).setDate(today.getDate() - 6), + ); + + // Query the datastore for all the data we collected + const runs = await queryRunningDatastore(client); + if (!runs.ok) { + return { error: `Failed to retrieve past runs: ${runs.error}` }; + } // Add run statistics to the associated runner runs.items.forEach((run) => { - const isRecentRun = run.rundate >= - startOfLastWeek.toLocaleDateString("en-CA", { timeZone: "UTC" }); + const isRecentRun = + run.rundate >= startOfLastWeek.toISOString().substring(0, 10); // Find existing runner record or create new one const runner = runners.get(run.runner) || diff --git a/functions/collect_runner_stats_test.ts b/functions/collect_runner_stats_test.ts new file mode 100644 index 0000000..458bcb5 --- /dev/null +++ b/functions/collect_runner_stats_test.ts @@ -0,0 +1,47 @@ +import * as mf from "mock-fetch/mod.ts"; +import { SlackFunctionTester } from "deno-slack-sdk/mod.ts"; +import { assertEquals } from "std/testing/asserts.ts"; +import CollectRunnerStatsFunction from "./collect_runner_stats.ts"; +import { DatastoreItem } from "deno-slack-api/types.ts"; +import RunningDatastore from "../datastores/run_data.ts"; + +// Mocked date for stable testing +Date.now = () => new Date("2023-01-06").getTime(); + +// Collection of runs stored in the mocked datastore +const mockRuns: DatastoreItem[] = [ + { id: "R006", runner: "U0123456", distance: 4, rundate: "2023-01-06" }, + { id: "R005", runner: "U0123456", distance: 2, rundate: "2023-01-06" }, + { id: "R004", runner: "U7777777", distance: 2, rundate: "2023-01-03" }, + { id: "R003", runner: "U0123456", distance: 4, rundate: "2022-12-31" }, + { id: "R002", runner: "U7777777", distance: 1, rundate: "2022-12-10" }, + { id: "R001", runner: "U0123456", distance: 2, rundate: "2022-11-11" }, +]; + +// Replaces globalThis.fetch with the mocked copy +mf.install(); + +mf.mock("POST@/api/apps.datastore.query", () => { + return new Response(JSON.stringify({ ok: true, items: mockRuns })); +}); + +const { createContext } = SlackFunctionTester("collect_runner_stats"); + +Deno.test("Collect runner stats", async () => { + const { outputs, error } = await CollectRunnerStatsFunction( + createContext({ inputs: {} }), + ); + + const expectedStats = [{ + runner: "U0123456", + weekly_distance: 10, + total_distance: 12, + }, { + runner: "U7777777", + weekly_distance: 2, + total_distance: 3, + }]; + + assertEquals(error, undefined); + assertEquals(outputs?.runner_stats, expectedStats); +}); diff --git a/functions/collect_team_stats.ts b/functions/collect_team_stats.ts index f4163bb..fafdb7c 100644 --- a/functions/collect_team_stats.ts +++ b/functions/collect_team_stats.ts @@ -1,6 +1,6 @@ import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts"; import { SlackAPIClient } from "deno-slack-sdk/types.ts"; -import RunningDatastore, { RUN_DATASTORE } from "../datastores/run_data.ts"; +import { queryRunningDatastore } from "../datastores/run_data.ts"; export const CollectTeamStatsFunction = DefineFunction({ callback_id: "collect_team_stats", @@ -15,10 +15,12 @@ export const CollectTeamStatsFunction = DefineFunction({ properties: { weekly_distance: { type: Schema.types.number, + title: "Weekly distance", description: "Total number of miles ran last week", }, percent_change: { type: Schema.types.number, + title: "Percent change", description: "Percent change of miles ran compared to the prior week", }, }, @@ -27,17 +29,21 @@ export const CollectTeamStatsFunction = DefineFunction({ }); export default SlackFunction(CollectTeamStatsFunction, async ({ client }) => { - const today = new Date(); + const today = new Date(Date.now()); // Collect runs from the past week (days 0-6) - const lastWeekStartDate = new Date(new Date().setDate(today.getDate() - 6)); + const lastWeekStartDate = new Date( + new Date(Date.now()).setDate(today.getDate() - 6), + ); const lastWeekDistance = await distanceInWeek(client, lastWeekStartDate); if (lastWeekDistance.error) { return { error: lastWeekDistance.error }; } // Collect runs from the prior week (days 7-13) - const priorWeekStartDate = new Date(new Date().setDate(today.getDate() - 13)); + const priorWeekStartDate = new Date( + new Date(Date.now()).setDate(today.getDate() - 13), + ); const priorWeekDistance = await distanceInWeek(client, priorWeekStartDate); if (priorWeekDistance.error) { return { error: priorWeekDistance.error }; @@ -58,7 +64,15 @@ export default SlackFunction(CollectTeamStatsFunction, async ({ client }) => { }; }); -// Sum all logged runs in the seven days following startDate +/** + * Sum all logged runs in the seven days following startDate + * + * @param client the client to interact with the Slack API + * @param startDate the beginning of the week to measure + * + * @returns total the sum of miles run over the measured week + * @returns error the description of any server error + */ async function distanceInWeek( client: SlackAPIClient, startDate: Date, @@ -66,18 +80,16 @@ async function distanceInWeek( const endDate = new Date(startDate); endDate.setDate(endDate.getDate() + 6); - const runs = await client.apps.datastore.query< - typeof RunningDatastore.definition - >({ - datastore: RUN_DATASTORE, + const expressions = { expression: "#date BETWEEN :start_date AND :end_date", expression_attributes: { "#date": "rundate" }, expression_values: { - ":start_date": startDate.toLocaleDateString("en-CA", { timeZone: "UTC" }), - ":end_date": endDate.toLocaleDateString("en-CA", { timeZone: "UTC" }), + ":start_date": startDate.toISOString().substring(0, 10), + ":end_date": endDate.toISOString().substring(0, 10), }, - }); + }; + const runs = await queryRunningDatastore(client, expressions); if (!runs.ok) { return { total: 0, error: `Failed to retrieve past runs: ${runs.error}` }; } diff --git a/functions/collect_team_stats_test.ts b/functions/collect_team_stats_test.ts new file mode 100644 index 0000000..f4d169c --- /dev/null +++ b/functions/collect_team_stats_test.ts @@ -0,0 +1,65 @@ +import * as mf from "mock-fetch/mod.ts"; +import { assertEquals } from "std/testing/asserts.ts"; +import { SlackFunctionTester } from "deno-slack-sdk/mod.ts"; +import { DatastoreItem } from "deno-slack-api/types.ts"; +import CollectTeamStatsFunction from "./collect_team_stats.ts"; +import RunningDatastore from "../datastores/run_data.ts"; + +// Mocked date for stable testing +Date.now = () => new Date("2023-01-06").getTime(); + +// Collection of runs stored in the mocked datastore +let mockRuns: DatastoreItem[]; + +// Replaces globalThis.fetch with the mocked copy +mf.install(); + +mf.mock("POST@/api/apps.datastore.query", async (args) => { + const body = await args.formData(); + const dates = JSON.parse(body.get("expression_values") as string); + const runs = mockRuns.filter((run) => ( + run.rundate >= dates[":start_date"] && run.rundate <= dates[":end_date"] + )); + return new Response(JSON.stringify({ ok: true, items: runs })); +}); + +const { createContext } = SlackFunctionTester("collect_team_stats"); + +Deno.test("Retrieve the empty set", async () => { + mockRuns = []; + const { outputs, error } = await CollectTeamStatsFunction( + createContext({ inputs: {} }), + ); + assertEquals(error, undefined); + assertEquals(outputs?.weekly_distance, 0); + assertEquals(outputs?.percent_change, 0); +}); + +Deno.test("Count only runs from the past week", async () => { + mockRuns = [ + { id: "R006", runner: "U0123456", distance: 8, rundate: "2023-01-07" }, + { id: "R005", runner: "U0123456", distance: 4, rundate: "2023-01-06" }, + { id: "R004", runner: "U7777777", distance: 2, rundate: "2023-01-02" }, + { id: "R003", runner: "U0123456", distance: 4, rundate: "2022-12-31" }, + { id: "R002", runner: "U7777777", distance: 6, rundate: "2022-12-31" }, + { id: "R001", runner: "U8888888", distance: 1, rundate: "2022-12-30" }, + ]; + const { outputs, error } = await CollectTeamStatsFunction( + createContext({ inputs: {} }), + ); + assertEquals(error, undefined); + assertEquals(outputs?.weekly_distance, 16); + assertEquals(outputs?.percent_change, 1500); +}); + +Deno.test("Handle the infinite change", async () => { + mockRuns = [ + { id: "R001", runner: "U0123456", distance: 10, rundate: "2023-01-05" }, + ]; + const { outputs, error } = await CollectTeamStatsFunction( + createContext({ inputs: {} }), + ); + assertEquals(error, undefined); + assertEquals(outputs?.weekly_distance, 10); + assertEquals(outputs?.percent_change, 0); +}); diff --git a/functions/format_leaderboard.ts b/functions/format_leaderboard.ts index bbbc7c0..74cae5a 100644 --- a/functions/format_leaderboard.ts +++ b/functions/format_leaderboard.ts @@ -10,16 +10,19 @@ export const FormatLeaderboardFunction = DefineFunction({ properties: { team_distance: { type: Schema.types.number, + title: "Team distance", description: "Total number of miles ran last week for the team", }, percent_change: { type: Schema.types.number, + title: "Percent change", description: "Percent change of miles ran compared to the prior week for the team", }, runner_stats: { type: Schema.types.array, items: { type: RunnerStatsType }, + title: "Runner stats", description: "Weekly and all-time total distances for runners", }, }, @@ -29,10 +32,12 @@ export const FormatLeaderboardFunction = DefineFunction({ properties: { teamStatsFormatted: { type: Schema.types.string, + title: "Formatted team stats", description: "A formatted message with team stats", }, runnerStatsFormatted: { type: Schema.types.string, + title: "Formatted runner stats", description: "An ordered leaderboard of runner stats", }, }, diff --git a/functions/format_leaderboard_test.ts b/functions/format_leaderboard_test.ts new file mode 100644 index 0000000..ecd8d07 --- /dev/null +++ b/functions/format_leaderboard_test.ts @@ -0,0 +1,38 @@ +import { SlackFunctionTester } from "deno-slack-sdk/mod.ts"; +import { assertEquals, assertStringIncludes } from "std/testing/asserts.ts"; +import FormatLeaderboardFunction from "./format_leaderboard.ts"; + +const { createContext } = SlackFunctionTester("format_leaderboard"); + +Deno.test("Collect team stats", async () => { + const inputs = { + team_distance: 11, + percent_change: 50, + runner_stats: [{ + runner: "U0123456", + weekly_distance: 4, + total_distance: 8, + }, { + runner: "U7777777", + weekly_distance: 2, + total_distance: 3, + }], + }; + + const { outputs, error } = await FormatLeaderboardFunction( + createContext({ inputs }), + ); + + assertEquals(error, undefined); + assertStringIncludes(outputs?.teamStatsFormatted || "", "11 miles"); + assertStringIncludes(outputs?.teamStatsFormatted || "", "50%"); + + assertStringIncludes( + outputs?.runnerStatsFormatted || "", + "<@U0123456> ran 4 miles last week (8 total)", + ); + assertStringIncludes( + outputs?.runnerStatsFormatted || "", + "<@U7777777> ran 2 miles last week (3 total)", + ); +}); diff --git a/functions/log_run_test.ts b/functions/log_run_test.ts index b493896..fb67302 100644 --- a/functions/log_run_test.ts +++ b/functions/log_run_test.ts @@ -1,6 +1,6 @@ import * as mf from "mock-fetch/mod.ts"; import { SlackFunctionTester } from "deno-slack-sdk/mod.ts"; -import { assertEquals } from "https://deno.land/std@0.153.0/testing/asserts.ts"; +import { assertEquals } from "std/testing/asserts.ts"; import LogRunFunction from "./log_run.ts"; // Replaces global fetch with the mocked copy @@ -16,7 +16,7 @@ Deno.test("Successfully save a run", async () => { const inputs = { runner: "U0123456", distance: 4, - rundate: "2022-01-22", + rundate: "2023-01-13", }; const { error } = await LogRunFunction(createContext({ inputs })); diff --git a/import_map.json b/import_map.json index c30eef5..c21fa4c 100644 --- a/import_map.json +++ b/import_map.json @@ -2,6 +2,7 @@ "imports": { "deno-slack-sdk/": "https://deno.land/x/deno_slack_sdk@2.3.0/", "deno-slack-api/": "https://deno.land/x/deno_slack_api@2.1.2/", + "std/": "https://deno.land/std@0.183.0/", "mock-fetch/": "https://deno.land/x/mock_fetch@0.3.0/" } }