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

Commit

Permalink
Gather all logged runs in a standard date format (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
zimeg authored Sep 28, 2023
1 parent edf57c9 commit ff65838
Show file tree
Hide file tree
Showing 9 changed files with 239 additions and 28 deletions.
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);

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);

// 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

0 comments on commit ff65838

Please sign in to comment.