-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #45 from Prodeko/analytics
Analytics
- Loading branch information
Showing
10 changed files
with
302 additions
and
39 deletions.
There are no files selected for viewing
33 changes: 33 additions & 0 deletions
33
prisma/migrations/20240504135407_add_points_to_entry/migration.sql
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
/* | ||
Warnings: | ||
- Added the required column `earnedPoints` to the `Entry` table without a default value. This is not possible if the table is not empty. | ||
- Added the required column `sportMultiplier` to the `Entry` table without a default value. This is not possible if the table is not empty. | ||
*/ | ||
-- AlterTable | ||
ALTER TABLE "Entry" ADD COLUMN "sportMultiplier" DOUBLE PRECISION; | ||
|
||
UPDATE "Entry" | ||
SET "sportMultiplier" = | ||
CASE | ||
WHEN "sport" = 'swim' THEN 5 | ||
WHEN "sport" IN ('run', 'walk') THEN 1 | ||
WHEN "sport" IN ('ski', 'rollerski', 'rollerblade', 'skateboard') THEN 0.5 | ||
WHEN "sport" = 'cycle' THEN 0.2 | ||
END; | ||
|
||
ALTER TABLE "Entry" | ||
ALTER COLUMN "sportMultiplier" SET NOT NULL; | ||
|
||
ALTER TABLE "Entry" ADD COLUMN "earnedPoints" DOUBLE PRECISION; | ||
|
||
UPDATE "Entry" | ||
SET "earnedPoints" = | ||
CASE | ||
WHEN "doublePoints" THEN "distance" * "sportMultiplier" * 2 | ||
ELSE "distance" * "sportMultiplier" | ||
END; | ||
|
||
ALTER TABLE "Entry" | ||
ALTER COLUMN "earnedPoints" SET NOT NULL; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import { prisma } from "../../config"; | ||
import type { Guild, Statistics, TeamStatistics } from "../common/types"; | ||
|
||
interface PeriodStats { | ||
guild: Guild; | ||
pointsGainedInPeriod: number; | ||
continuingParticipants: number; | ||
proportionOfContinuingParticipants: number; | ||
} | ||
|
||
interface Aggregate { | ||
guild: Guild; | ||
totalPoints: number; | ||
totalKilometers: number; | ||
totalEntries: number; | ||
numberOfUniqueParticipants: number; | ||
milestoneAchievers: number; | ||
proportionOfMilestoneAchievers: number; | ||
} | ||
|
||
// Function to calculate and return the total points | ||
export async function calculateGuildStatistics( | ||
periodStart: Date, | ||
periodEnd: Date, | ||
): Promise<Statistics> { | ||
const aggregates = (await prisma.$queryRaw` | ||
SELECT | ||
guild, | ||
SUM("earnedPoints") as "totalPoints", | ||
SUM(distance) as "totalKilometers", | ||
COUNT(*) as "totalEntries", | ||
COUNT(DISTINCT user) as "numberOfUniqueParticipants", | ||
COUNT(CASE WHEN "earnedPoints" >= 50 THEN 1 END)/COUNT(DISTINCT user) as "proportionOfMilestoneAchievers" | ||
FROM | ||
"Entry" JOIN "User" ON "Entry"."userId" = "User"."telegramUserId" | ||
GROUP BY | ||
guild | ||
`) as Aggregate[]; | ||
|
||
const periodStats = (await prisma.$queryRaw` | ||
WITH period_stats AS ( | ||
SELECT | ||
guild, | ||
SUM("earnedPoints") as "pointsGainedInPeriod", | ||
COUNT(DISTINCT user) as "continuingParticipants" | ||
FROM | ||
"Entry" JOIN "User" ON "Entry"."userId" = "User"."telegramUserId" | ||
WHERE | ||
"Entry"."createdAt" BETWEEN ${periodStart} AND ${periodEnd} | ||
GROUP BY | ||
guild | ||
), | ||
previous_users AS ( | ||
SELECT | ||
guild, | ||
COUNT(DISTINCT user) as "previousParticipants" | ||
FROM | ||
"Entry" JOIN "User" ON "Entry"."userId" = "User"."telegramUserId" | ||
WHERE | ||
"Entry"."createdAt" < ${periodStart} | ||
GROUP BY | ||
guild | ||
) | ||
SELECT | ||
period_stats.guild, | ||
"pointsGainedInPeriod", | ||
CASE WHEN "previousParticipants" = 0 THEN 0 ELSE "continuingParticipants"/"previousParticipants" END as "proportionOfContinuingParticipants" | ||
FROM | ||
period_stats LEFT JOIN previous_users ON period_stats.guild = previous_users.guild | ||
`) as PeriodStats[]; | ||
|
||
const entriesInPeriod = await prisma.entry.findMany({ | ||
where: { | ||
createdAt: { | ||
gte: periodStart, | ||
lte: periodEnd, | ||
}, | ||
}, | ||
}); | ||
|
||
const previousUsers = (await prisma.$queryRaw` | ||
SELECT | ||
guild, | ||
COUNT(DISTINCT user) as "previousParticipants" | ||
FROM | ||
"Entry" JOIN "User" ON "Entry"."userId" = "User"."telegramUserId" | ||
WHERE | ||
"Entry"."createdAt" < ${periodStart} | ||
GROUP BY | ||
guild | ||
`) as { guild: Guild; previousParticipants: number }[]; | ||
|
||
console.log(entriesInPeriod); | ||
const statistics = new Map<Guild, TeamStatistics>(); | ||
|
||
for (const aggregate of aggregates) { | ||
const periodStat = periodStats.find( | ||
(stat) => stat.guild === aggregate.guild, | ||
); | ||
console.log("period", periodStats); | ||
statistics.set(aggregate.guild, { | ||
totalPoints: aggregate.totalPoints, | ||
totalKilometers: aggregate.totalKilometers, | ||
totalEntries: Number(aggregate.totalEntries), | ||
numberOfUniqueParticipants: Number(aggregate.numberOfUniqueParticipants), | ||
proportionOfContinuingParticipants: | ||
Number(periodStat?.proportionOfContinuingParticipants) || 0, | ||
pointsGainedInPeriod: Number(periodStat?.pointsGainedInPeriod) || 0, | ||
proportionOfMilestoneAchievers: Number( | ||
aggregate.proportionOfMilestoneAchievers, | ||
), | ||
}); | ||
} | ||
|
||
return statistics; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import express from "express"; | ||
import { saveEntriesAsCSV } from "../entries"; | ||
import statisticsRouter from "./statistics/routes"; | ||
|
||
// biome-ignore lint/style/noNonNullAssertion: <explanation> | ||
const port = Number.parseInt(process.env.PORT!); | ||
|
||
const app = express(); | ||
|
||
// Necessary because of Azure App Service health check on startup | ||
app.get("/", (_req, res) => { | ||
res.status(200).send("Kovaa tulee"); | ||
}); | ||
|
||
app.get("/health", (_req, res) => { | ||
res.status(200).send("OK"); | ||
}); | ||
|
||
app.use(async (req, res, next) => { | ||
if (req.query.pass !== process.env.ADMIN_PASSWORD) { | ||
console.log("Wrong password"); | ||
return res.status(401).send("Wrong password!"); | ||
} | ||
next(); | ||
}) | ||
|
||
app.get("/entries", async (req, res) => { | ||
await saveEntriesAsCSV(); | ||
res.attachment("./entries.csv"); | ||
res.header("Content-Type", "text/csv"); | ||
res.status(200).send(fs.readFileSync("./entries.csv")); | ||
}); | ||
|
||
app.use("/statistics", statisticsRouter); | ||
|
||
export const launchServer = async () => { | ||
app.listen(port, () => console.log("Running on port ", port)); | ||
} | ||
|
||
export default app; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import type { NextFunction, Request, Response } from "express"; | ||
|
||
export const validatePeriod = (req: Request, res: Response, next: NextFunction) => { | ||
const start = req.query.start; | ||
const end = req.query.end; | ||
|
||
if (!start || !end) { | ||
return res.status(400).send('Period start and end query parameters are required'); | ||
} | ||
|
||
if (typeof start !== 'string' || typeof end !== 'string') { | ||
return res.status(400).send('Invalid period start and end query parameters'); | ||
} | ||
|
||
if (Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) { | ||
return res.status(400).send('Period start and end query parameters must be valid dates'); | ||
} | ||
|
||
const startDate = new Date(start); | ||
const endDate = new Date(end); | ||
|
||
if (startDate > endDate) { | ||
return res.status(400).send('Period start must be before period end'); | ||
} | ||
|
||
res.locals.periodStart = startDate; | ||
res.locals.periodEnd = endDate; | ||
|
||
next(); | ||
} |
Oops, something went wrong.