Skip to content
This repository has been archived by the owner on Oct 31, 2024. It is now read-only.

Gather all logged runs in a standard date format #23

Merged
merged 6 commits into from
Sep 28, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions datastores/run_data.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<typeof RunningDatastore.definition>[];
error?: string;
}> {
const items: DatastoreItem<typeof RunningDatastore.definition>[] = [];
let cursor = undefined;

do {
const runs: DatastoreQueryResponse<typeof RunningDatastore.definition> =
await client.apps.datastore.query<typeof RunningDatastore.definition>({
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do/while w/ cursors in Dynamo is exactly how I structure my DDB pagination calls 👍


return { ok: true, items };
}

export default RunningDatastore;
28 changes: 14 additions & 14 deletions functions/collect_runner_stats.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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",
},
},
Expand All @@ -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<typeof Schema.slack.types.user_id, {
runner: typeof Schema.slack.types.user_id;
total_distance: number;
weekly_distance: number;
}>();

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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👨‍🍳 💋

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISO-8601 just feels so much better 🙌 🙌

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISO8601 is the best date standard IMO


// Find existing runner record or create new one
const runner = runners.get(run.runner) ||
Expand Down
47 changes: 47 additions & 0 deletions functions/collect_runner_stats_test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof RunningDatastore.definition>[] = [
{ 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);
});
36 changes: 24 additions & 12 deletions functions/collect_team_stats.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
},
},
Expand All @@ -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 };
Expand All @@ -58,26 +64,32 @@ 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,
): Promise<{ total: number; error?: string }> {
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}` };
}
Expand Down
65 changes: 65 additions & 0 deletions functions/collect_team_stats_test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof RunningDatastore.definition>[];

// 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);
});
5 changes: 5 additions & 0 deletions functions/format_leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
Expand All @@ -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",
},
},
Expand Down
38 changes: 38 additions & 0 deletions functions/format_leaderboard_test.ts
Original file line number Diff line number Diff line change
@@ -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)",
);
});
4 changes: 2 additions & 2 deletions functions/log_run_test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }));
Expand Down
Loading