From 74b7fa8f0e49b7bba3df4f82155f165273d86ba6 Mon Sep 17 00:00:00 2001 From: "sebastien.arod@gmail.com" Date: Sun, 2 Jun 2024 14:34:11 +0200 Subject: [PATCH] feat: base infra and first statistics --- README.md | 9 +- src/data/Family.ts | 86 +++++++++++++++++++ src/data/checkDataConsistency.ts | 63 ++++++++++++++ src/index.test.ts | 8 -- src/index.ts | 40 ++++++++- .../fetchFamiliesWithEventsFromNotion.ts | 50 +++++++++++ src/notion/publish/publishCurrentStats.ts | 73 ++++++++++++++++ .../publish/publishStatisticsToNotion.ts | 69 +++++++++++++++ src/notion/utils/listAllChildrenBlocks.ts | 22 +++++ .../utils/properties/datePropertyToDate.ts | 18 ++++ .../properties/extractPagePropertyValue.ts | 13 +++ .../utils/properties/statusPropertyToText.ts | 15 ++++ .../properties/titlePropertyToText copy.ts | 16 ++++ src/notion/utils/queryAllDbResults.ts | 21 +++++ src/notion/utils/removeBlocks.ts | 12 +++ .../text/paragraphBlockChildrenToPlainText.ts | 19 ++++ .../utils/text/richTextPropertyToPlainText.ts | 17 ++++ src/notion/utils/text/richTextToPlainText.ts | 13 +++ .../utils/types/PageOrDatabaseResponse.ts | 8 ++ src/notion/utils/types/PageProperties.ts | 3 + src/notion/utils/types/PagePropertyValue.ts | 3 + src/notion/utils/types/assertFullPage.ts | 23 +++++ src/period/IdentifiedPeriod.ts | 5 ++ src/period/Period.ts | 5 ++ src/period/arePeriodsOverlaping.ts | 12 +++ .../generateConsecutiveIdentifiedPeriods.ts | 27 ++++++ src/period/generateConsecutivePeriods.test.ts | 24 ++++++ src/period/generateConsecutivePeriods.ts | 30 +++++++ src/period/isPeriodContaining.test.ts | 62 +++++++++++++ src/period/isPeriodContaining.ts | 18 ++++ src/statistiques/ELStats.ts | 21 +++++ src/statistiques/computeELPeriodStats.ts | 41 +++++++++ src/statistiques/computeELStats.ts | 25 ++++++ src/statistiques/generateELMonths.ts | 29 +++++++ src/statistiques/generateELYears.ts | 14 +++ 35 files changed, 898 insertions(+), 16 deletions(-) create mode 100644 src/data/Family.ts create mode 100644 src/data/checkDataConsistency.ts delete mode 100644 src/index.test.ts create mode 100644 src/notion/fetch/fetchFamiliesWithEventsFromNotion.ts create mode 100644 src/notion/publish/publishCurrentStats.ts create mode 100644 src/notion/publish/publishStatisticsToNotion.ts create mode 100644 src/notion/utils/listAllChildrenBlocks.ts create mode 100644 src/notion/utils/properties/datePropertyToDate.ts create mode 100644 src/notion/utils/properties/extractPagePropertyValue.ts create mode 100644 src/notion/utils/properties/statusPropertyToText.ts create mode 100644 src/notion/utils/properties/titlePropertyToText copy.ts create mode 100644 src/notion/utils/queryAllDbResults.ts create mode 100644 src/notion/utils/removeBlocks.ts create mode 100644 src/notion/utils/text/paragraphBlockChildrenToPlainText.ts create mode 100644 src/notion/utils/text/richTextPropertyToPlainText.ts create mode 100644 src/notion/utils/text/richTextToPlainText.ts create mode 100644 src/notion/utils/types/PageOrDatabaseResponse.ts create mode 100644 src/notion/utils/types/PageProperties.ts create mode 100644 src/notion/utils/types/PagePropertyValue.ts create mode 100644 src/notion/utils/types/assertFullPage.ts create mode 100644 src/period/IdentifiedPeriod.ts create mode 100644 src/period/Period.ts create mode 100644 src/period/arePeriodsOverlaping.ts create mode 100644 src/period/generateConsecutiveIdentifiedPeriods.ts create mode 100644 src/period/generateConsecutivePeriods.test.ts create mode 100644 src/period/generateConsecutivePeriods.ts create mode 100644 src/period/isPeriodContaining.test.ts create mode 100644 src/period/isPeriodContaining.ts create mode 100644 src/statistiques/ELStats.ts create mode 100644 src/statistiques/computeELPeriodStats.ts create mode 100644 src/statistiques/computeELStats.ts create mode 100644 src/statistiques/generateELMonths.ts create mode 100644 src/statistiques/generateELYears.ts diff --git a/README.md b/README.md index a61677a..9f2eb8a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # Statistiques +This modules computes EL statistiques from Notion Data + # Setup -- Configure a .env defining the NOTION_TOKEN variable -- Run - `yarn install - yarn run update-stats -` +- Create file `.env` defining the NOTION_TOKEN variable +- Run `yarn install && yarn run start` diff --git a/src/data/Family.ts b/src/data/Family.ts new file mode 100644 index 0000000..59a0e32 --- /dev/null +++ b/src/data/Family.ts @@ -0,0 +1,86 @@ +import { arePeriodsOverlaping } from "../period/arePeriodsOverlaping"; +import { Period } from "../period/Period"; + +export type FamilyStatus = + | "Résistant.e" + | "Ex résistant·e·s" + | "À préciser" + | "Se questionne" + | "Désobéissence décidée" + | "Rédaction Déclaration" + | "Déclaration Validée - Attente éléments" + | "Abdandon" + | "Incompatible"; +export type Family = { + notionId: string; + title: string; + status: FamilyStatus; + + startResistsant: Date | null; + endResistant: Date | null; + evenements: FamilyEvent[]; +}; + +export function isResistant(family: Family): boolean { + return ( + family.status === "Résistant.e" && + family.startResistsant !== null && + family.endResistant === null + ); +} + +export function isExResistant(family: Family): boolean { + return ( + family.status === "Ex résistant·e·s" && + family.startResistsant !== null && + family.endResistant !== null + ); +} + +export function isResistantAtDate(family: Family, date: Date): boolean { + if ( + isResistant(family) && + family.startResistsant!.getTime() <= date.getTime() + ) { + return true; + } + if ( + isExResistant(family) && + family.startResistsant!.getTime() <= date.getTime() && + family.endResistant!.getTime() > date.getTime() + ) { + return true; + } + return false; +} + +export function isResistantOverPeriod(family: Family, period: Period): boolean { + const familyPeriodResistant: Period | null = periodOfResistance(family); + return ( + familyPeriodResistant !== null && + arePeriodsOverlaping(familyPeriodResistant, period) + ); +} + +function periodOfResistance(family: Family): Period | null { + if (isResistant(family)) { + const periodResistant: Period = { + start: family.startResistsant!, + end: new Date(Date.now()), + }; + return periodResistant; + } + if (isExResistant(family)) { + const periodResistant: Period = { + start: family.startResistsant!, + end: family.endResistant!, + }; + return periodResistant; + } + return null; +} + +export type FamilyEvent = { + date: Date; + type: string; +}; diff --git a/src/data/checkDataConsistency.ts b/src/data/checkDataConsistency.ts new file mode 100644 index 0000000..65d7378 --- /dev/null +++ b/src/data/checkDataConsistency.ts @@ -0,0 +1,63 @@ +import { Family } from "./Family"; + +export function checkDataConsistency(families: Family[]): ConsistencyIssue[] { + return families.flatMap((family) => { + return checkFamilyDataConsistency(family); + }); +} + +export type ConsistencyIssue = { + issueType: string; + familyId: string; +}; +function checkFamilyDataConsistency(family: Family) { + const consistencyIssues: ConsistencyIssue[] = []; + + if (family.status === "Résistant.e") { + if (family.startResistsant === null) { + consistencyIssues.push({ + familyId: family.title, + issueType: "Résistant.e without startResistant", + }); + } + if (family.endResistant !== null) { + consistencyIssues.push({ + familyId: family.title, + issueType: "Résistant.e with endResistant!!", + }); + } + } else if (family.status === "Ex résistant·e·s") { + if (family.startResistsant === null) { + consistencyIssues.push({ + familyId: family.title, + issueType: "Ex résistant.e.s without startResistant", + }); + } + if (family.endResistant === null) { + consistencyIssues.push({ + familyId: family.title, + issueType: "Ex résistant.e.s without endResistant", + }); + } + if (family.startResistsant!.getTime() > family.endResistant!.getTime()) { + consistencyIssues.push({ + familyId: family.title, + issueType: "startResistsant > endResistant ", + }); + } + } else { + if (family.startResistsant !== null) { + consistencyIssues.push({ + familyId: family.title, + issueType: family.status + " with startResistant", + }); + } + if (family.endResistant !== null) { + consistencyIssues.push({ + familyId: family.title, + issueType: family.status + " with endResistant", + }); + } + } + return consistencyIssues; +} diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index d576f74..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, test } from "@jest/globals"; -import { hello } from "."; - -describe("module", () => { - test("test hello", () => { - expect(hello()).toBe("hello"); - }); -}); diff --git a/src/index.ts b/src/index.ts index 5d6f61b..ff8d4c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,37 @@ -export function hello() { - return "hello"; -} +import { Client } from "@notionhq/client"; +import { checkDataConsistency } from "./data/checkDataConsistency"; +import { fetchFamiliesWithEventsFromNotion } from "./notion/fetch/fetchFamiliesWithEventsFromNotion"; +import { publishStatisticsToNotion } from "./notion/publish/publishStatisticsToNotion"; +import { computeELStats } from "./statistiques/computeELStats"; + +(async function IIFE() { + const notionApiToken = process.env.NOTION_TOKEN; + if (!notionApiToken) { + throw new Error("process.env.NOTION_TOKEN not defined"); + } + + const notionClient = new Client({ + auth: notionApiToken, + }); + + const doFetch = true; + console.log("Fetching families..."); + const families = doFetch + ? await fetchFamiliesWithEventsFromNotion(notionClient) + : []; + + console.log("Checking Data Consistency issues..."); + const consistencyIssues = checkDataConsistency(families); + if (consistencyIssues.length > 0) { + console.log("Found consistency issues:"); + console.log(consistencyIssues); + } + + console.log("Building statistics..."); + const resistantCountStats = computeELStats(families); + + console.log(resistantCountStats); + + console.log("Publishing statistics..."); + publishStatisticsToNotion(resistantCountStats, notionClient); +})(); diff --git a/src/notion/fetch/fetchFamiliesWithEventsFromNotion.ts b/src/notion/fetch/fetchFamiliesWithEventsFromNotion.ts new file mode 100644 index 0000000..e29df69 --- /dev/null +++ b/src/notion/fetch/fetchFamiliesWithEventsFromNotion.ts @@ -0,0 +1,50 @@ +import { Client } from "@notionhq/client"; +import { + PageObjectResponse, + QueryDatabaseParameters, +} from "@notionhq/client/build/src/api-endpoints"; +import { Family, FamilyStatus } from "../../data/Family"; +import { datePropertyToDate } from "../utils/properties/datePropertyToDate"; +import { statusPropertyToText } from "../utils/properties/statusPropertyToText"; +import { titlePropertyToText } from "../utils/properties/titlePropertyToText copy"; +import { queryAllDbResults } from "../utils/queryAllDbResults"; +import { assertFullPage } from "../utils/types/assertFullPage"; + +export async function fetchFamiliesWithEventsFromNotion( + notionClient: Client +): Promise { + const familiesDbId: string = "5b69e02b296d4a578f8c8ab7fe8b05da"; + const dbQuery: QueryDatabaseParameters = { + database_id: familiesDbId, + /*filter: { + property: "Statut", + status: { + equals: "Résistant.e", + }, + },*/ + }; + + const results = await queryAllDbResults(notionClient, dbQuery); + const families: Family[] = await Promise.all( + results.map((pageObjectResponse) => { + assertFullPage(pageObjectResponse); + return buildFamily(pageObjectResponse); + }) + ); + return families; +} + +function buildFamily(page: PageObjectResponse): Family { + const pageProperties = page.properties; + + // TODO Fetch Family Events + const family: Family = { + notionId: page.id, + title: titlePropertyToText(pageProperties, ""), + status: statusPropertyToText(pageProperties, "Statut") as FamilyStatus, + startResistsant: datePropertyToDate(pageProperties, "Intégration"), + endResistant: datePropertyToDate(pageProperties, "Sortie"), + evenements: [], + }; + return family; +} diff --git a/src/notion/publish/publishCurrentStats.ts b/src/notion/publish/publishCurrentStats.ts new file mode 100644 index 0000000..8470c48 --- /dev/null +++ b/src/notion/publish/publishCurrentStats.ts @@ -0,0 +1,73 @@ +import { Client, isFullBlock } from "@notionhq/client"; +import { BlockObjectRequest } from "@notionhq/client/build/src/api-endpoints"; +import { ELStats } from "../../statistiques/ELStats"; +import { listAllChildrenBlocks } from "../utils/listAllChildrenBlocks"; +import { removeBlocks } from "../utils/removeBlocks"; +import { richTextToPlainText } from "../utils/text/richTextToPlainText"; +import { currentStatsHeading, statsPageId } from "./publishStatisticsToNotion"; + +export async function publishCurrentStats( + notionClient: Client, + stats: ELStats +) { + const childrenBlocks = ( + await listAllChildrenBlocks(notionClient, { + block_id: statsPageId, + }) + ).filter(isFullBlock); + + const currentStatsHeadingBlockIndex = childrenBlocks.findIndex( + (block) => + block.type === "heading_1" && + richTextToPlainText(block.heading_1.rich_text) === currentStatsHeading + ); + const endOfCurrentStats = childrenBlocks.findIndex( + (block, index) => + index > currentStatsHeadingBlockIndex && block.type === "heading_1" + ); + if (currentStatsHeadingBlockIndex === -1 || endOfCurrentStats === -1) { + throw new Error("Cannot find expected headings in statistics page"); + } + + const currentStatsHeadingBlock = + childrenBlocks[currentStatsHeadingBlockIndex]; + + const blocksIdsToRemove = childrenBlocks + .slice(currentStatsHeadingBlockIndex + 1, endOfCurrentStats) + .map((b) => b.id); + await removeBlocks(notionClient, blocksIdsToRemove); + + notionClient.blocks.children.append({ + block_id: statsPageId, + after: currentStatsHeadingBlock.id, + children: [ + currentStatBlock("Nb Famille Résistante", stats.resistantsCount), + currentStatBlock( + "Nb Famille Résistante ou Ex-Résistante", + stats.resistantsOrExCount + ), + ], + }); +} + +function currentStatBlock( + stateName: string, + value: number +): BlockObjectRequest { + return { + paragraph: { + rich_text: [ + { + text: { + content: + stateName + + ": " + + value.toLocaleString("us-FR", { + maximumFractionDigits: 0, + }), + }, + }, + ], + }, + }; +} diff --git a/src/notion/publish/publishStatisticsToNotion.ts b/src/notion/publish/publishStatisticsToNotion.ts new file mode 100644 index 0000000..b4abacf --- /dev/null +++ b/src/notion/publish/publishStatisticsToNotion.ts @@ -0,0 +1,69 @@ +import { Client, isFullPage } from "@notionhq/client"; +import { ELPeriodStats, ELStats } from "../../statistiques/ELStats"; +import { queryAllDbResults } from "../utils/queryAllDbResults"; +import { removeBlocks } from "../utils/removeBlocks"; +import { publishCurrentStats } from "./publishCurrentStats"; + +export const statsPageId = "2b91cd90e3694e96bb196d69aeca59b1"; +export const currentStatsHeading = "Statistiques actuelles"; + +const yearStatsDb = "4b19a72aa07840eab948525ea41878ee"; +const monthStatsDb = "8418a8a4a7544f6a8c54e6003be7efe5"; +export async function publishStatisticsToNotion( + stats: ELStats, + notionClient: Client +) { + await publishCurrentStats(notionClient, stats); + + await publishPeriodStats(notionClient, yearStatsDb, stats.years); + + await publishPeriodStats(notionClient, monthStatsDb, stats.months); +} + +async function publishPeriodStats( + notionClient: Client, + periodStatsDbId: string, + stats: ELPeriodStats[] +) { + const periodRows = ( + await queryAllDbResults(notionClient, { + database_id: periodStatsDbId, + }) + ).filter(isFullPage); + await removeBlocks( + notionClient, + periodRows.map((r) => r.id) + ); + + for (const stat of stats) { + await notionClient.pages.create({ + parent: { + database_id: periodStatsDbId, + }, + properties: { + Période: { + title: [ + { + text: { + content: stat.periodId, + }, + }, + ], + }, + "Nb Famille Résistante": numberProp(stat.resistantsCount), + "Nb Famille Résistante - Evol": numberProp(stat.resistantsCountEvol), + "Nb Famille Résistante - Evol %": numberProp( + stat.resistantsCountEvolPercent + ), + }, + }); + } +} + +function numberProp(n: number): { + number: number; +} { + return { + number: n, + }; +} diff --git a/src/notion/utils/listAllChildrenBlocks.ts b/src/notion/utils/listAllChildrenBlocks.ts new file mode 100644 index 0000000..de7e485 --- /dev/null +++ b/src/notion/utils/listAllChildrenBlocks.ts @@ -0,0 +1,22 @@ +import { Client } from "@notionhq/client"; +import { + BlockObjectResponse, + ListBlockChildrenParameters, + PartialBlockObjectResponse, +} from "@notionhq/client/build/src/api-endpoints"; + +export async function listAllChildrenBlocks( + notion: Client, + query: ListBlockChildrenParameters +): Promise> { + const response = await notion.blocks.children.list(query); + if (response.has_more && response.next_cursor) { + const moreResults = await listAllChildrenBlocks(notion, { + ...query, + start_cursor: response.next_cursor, + }); + return [...response.results, ...moreResults]; + } else { + return response.results; + } +} diff --git a/src/notion/utils/properties/datePropertyToDate.ts b/src/notion/utils/properties/datePropertyToDate.ts new file mode 100644 index 0000000..4b10505 --- /dev/null +++ b/src/notion/utils/properties/datePropertyToDate.ts @@ -0,0 +1,18 @@ +import { PageProperties } from "../types/PageProperties"; +import { extractPagePropertyValue } from "./extractPagePropertyValue"; + +export function datePropertyToDate( + pageProperties: PageProperties, + propName: string +): Date | null { + const propValue = extractPagePropertyValue(pageProperties, propName); + if (propValue.type !== "date") { + throw new Error( + `Property ${propName} was expected to have type "date" but got "${propValue.type}".` + ); + } + if (propValue.date === null) { + return null; + } + return new Date(Date.parse(propValue.date.start)); +} diff --git a/src/notion/utils/properties/extractPagePropertyValue.ts b/src/notion/utils/properties/extractPagePropertyValue.ts new file mode 100644 index 0000000..7146014 --- /dev/null +++ b/src/notion/utils/properties/extractPagePropertyValue.ts @@ -0,0 +1,13 @@ +import { PageProperties } from "../types/PageProperties"; +import { PagePropertyValue } from "../types/PagePropertyValue"; + +export function extractPagePropertyValue( + pageProperties: PageProperties, + propName: string +): PagePropertyValue { + const propValue = pageProperties[propName]; + if (!propValue) { + throw new Error(`Prop ${propName} is missing.`); + } + return propValue; +} diff --git a/src/notion/utils/properties/statusPropertyToText.ts b/src/notion/utils/properties/statusPropertyToText.ts new file mode 100644 index 0000000..034f95e --- /dev/null +++ b/src/notion/utils/properties/statusPropertyToText.ts @@ -0,0 +1,15 @@ +import { PageProperties } from "../types/PageProperties"; +import { extractPagePropertyValue } from "./extractPagePropertyValue"; + +export function statusPropertyToText( + pageProperties: PageProperties, + propName: string +): string { + const propValue = extractPagePropertyValue(pageProperties, propName); + if (propValue.type !== "status") { + throw new Error( + `Property ${propName} was expected to have type "title" but got "${propValue.type}".` + ); + } + return propValue.status!.name; +} diff --git a/src/notion/utils/properties/titlePropertyToText copy.ts b/src/notion/utils/properties/titlePropertyToText copy.ts new file mode 100644 index 0000000..62633f1 --- /dev/null +++ b/src/notion/utils/properties/titlePropertyToText copy.ts @@ -0,0 +1,16 @@ +import { richTextToPlainText } from "../text/richTextToPlainText"; +import { PageProperties } from "../types/PageProperties"; +import { extractPagePropertyValue } from "./extractPagePropertyValue"; + +export function titlePropertyToText( + pageProperties: PageProperties, + propName: string +): string { + const propValue = extractPagePropertyValue(pageProperties, propName); + if (propValue.type !== "title") { + throw new Error( + `Property ${propName} was expected to have type "title" but got "${propValue.type}".` + ); + } + return richTextToPlainText(propValue.title); +} diff --git a/src/notion/utils/queryAllDbResults.ts b/src/notion/utils/queryAllDbResults.ts new file mode 100644 index 0000000..8a02606 --- /dev/null +++ b/src/notion/utils/queryAllDbResults.ts @@ -0,0 +1,21 @@ +import { Client } from "@notionhq/client"; +import { + QueryDatabaseParameters, + QueryDatabaseResponse, +} from "@notionhq/client/build/src/api-endpoints"; + +export async function queryAllDbResults( + notion: Client, + dbQuery: QueryDatabaseParameters +): Promise { + const dbResponse = await notion.databases.query(dbQuery); + if (dbResponse.has_more && dbResponse.next_cursor) { + const moreResults = await queryAllDbResults(notion, { + ...dbQuery, + start_cursor: dbResponse.next_cursor, + }); + return [...dbResponse.results, ...moreResults]; + } else { + return dbResponse.results; + } +} diff --git a/src/notion/utils/removeBlocks.ts b/src/notion/utils/removeBlocks.ts new file mode 100644 index 0000000..b0af026 --- /dev/null +++ b/src/notion/utils/removeBlocks.ts @@ -0,0 +1,12 @@ +import { Client } from "@notionhq/client"; + +export async function removeBlocks( + notionClient: Client, + blocksIdsToRemove: string[] +) { + for (const id of blocksIdsToRemove) { + await notionClient.blocks.delete({ + block_id: id, + }); + } +} diff --git a/src/notion/utils/text/paragraphBlockChildrenToPlainText.ts b/src/notion/utils/text/paragraphBlockChildrenToPlainText.ts new file mode 100644 index 0000000..be43051 --- /dev/null +++ b/src/notion/utils/text/paragraphBlockChildrenToPlainText.ts @@ -0,0 +1,19 @@ +import { isFullBlock } from "@notionhq/client"; +import { + ListBlockChildrenResponse, + ParagraphBlockObjectResponse, +} from "@notionhq/client/build/src/api-endpoints"; +import { richTextToPlainText } from "./richTextToPlainText"; + +export function paragraphBlockChildrenToPlainText( + results: ListBlockChildrenResponse["results"] +): string { + return results + .filter(isFullBlock) + .filter((b) => b.type === "paragraph") + .map((b) => { + const p = b as ParagraphBlockObjectResponse; + return richTextToPlainText(p.paragraph.rich_text); + }) + .join("\n"); +} diff --git a/src/notion/utils/text/richTextPropertyToPlainText.ts b/src/notion/utils/text/richTextPropertyToPlainText.ts new file mode 100644 index 0000000..11ff955 --- /dev/null +++ b/src/notion/utils/text/richTextPropertyToPlainText.ts @@ -0,0 +1,17 @@ +import { richTextToPlainText } from "./richTextToPlainText"; +import { extractPagePropertyValue } from "../properties/extractPagePropertyValue"; +import { PageProperties } from "../types/PageProperties"; + +export function richTextPropertyToPlainText( + pageProperties: PageProperties, + propName: string +): string { + const propValue = extractPagePropertyValue(pageProperties, propName); + if (propValue.type !== "rich_text") { + throw new Error( + `Property ${propName} was expected to have type "rich_text" but got "${propValue.type}".` + ); + } + const propText = richTextToPlainText(propValue.rich_text); + return propText; +} diff --git a/src/notion/utils/text/richTextToPlainText.ts b/src/notion/utils/text/richTextToPlainText.ts new file mode 100644 index 0000000..b01a465 --- /dev/null +++ b/src/notion/utils/text/richTextToPlainText.ts @@ -0,0 +1,13 @@ +import { RichTextItemResponse } from "@notionhq/client/build/src/api-endpoints"; + +export function richTextToPlainText(rich_texts: Array) { + return rich_texts + .map((rt) => { + if (rt.href) { + return rt.plain_text + " (" + rt.href + ")"; + } else { + return rt.plain_text; + } + }) + .join(""); +} diff --git a/src/notion/utils/types/PageOrDatabaseResponse.ts b/src/notion/utils/types/PageOrDatabaseResponse.ts new file mode 100644 index 0000000..e243735 --- /dev/null +++ b/src/notion/utils/types/PageOrDatabaseResponse.ts @@ -0,0 +1,8 @@ +import { + DatabaseObjectResponse, + PageObjectResponse, +} from "@notionhq/client/build/src/api-endpoints"; + +export type PageOrDatabaseResponse = + | PageObjectResponse + | DatabaseObjectResponse; diff --git a/src/notion/utils/types/PageProperties.ts b/src/notion/utils/types/PageProperties.ts new file mode 100644 index 0000000..2d2a0b8 --- /dev/null +++ b/src/notion/utils/types/PageProperties.ts @@ -0,0 +1,3 @@ +import { PageObjectResponse } from "@notionhq/client/build/src/api-endpoints"; + +export type PageProperties = PageObjectResponse["properties"]; diff --git a/src/notion/utils/types/PagePropertyValue.ts b/src/notion/utils/types/PagePropertyValue.ts new file mode 100644 index 0000000..206dc07 --- /dev/null +++ b/src/notion/utils/types/PagePropertyValue.ts @@ -0,0 +1,3 @@ +import { PageProperties } from "./PageProperties"; + +export type PagePropertyValue = PageProperties["x"]; diff --git a/src/notion/utils/types/assertFullPage.ts b/src/notion/utils/types/assertFullPage.ts new file mode 100644 index 0000000..c29a55d --- /dev/null +++ b/src/notion/utils/types/assertFullPage.ts @@ -0,0 +1,23 @@ +import { isFullPage } from "@notionhq/client"; +import { + BlockObjectResponse, + DatabaseObjectResponse, + PageObjectResponse, + PartialBlockObjectResponse, + PartialDatabaseObjectResponse, + PartialPageObjectResponse, +} from "@notionhq/client/build/src/api-endpoints"; + +export function assertFullPage( + response: + | PageObjectResponse + | PartialPageObjectResponse + | DatabaseObjectResponse + | PartialDatabaseObjectResponse + | BlockObjectResponse + | PartialBlockObjectResponse +): asserts response is PageObjectResponse { + if (!isFullPage(response)) { + throw new Error("Expecting a full page"); + } +} diff --git a/src/period/IdentifiedPeriod.ts b/src/period/IdentifiedPeriod.ts new file mode 100644 index 0000000..789f9a8 --- /dev/null +++ b/src/period/IdentifiedPeriod.ts @@ -0,0 +1,5 @@ +import { Period } from "./Period"; + +export type IdentifiedPeriod = { + id: string; +} & Period; diff --git a/src/period/Period.ts b/src/period/Period.ts new file mode 100644 index 0000000..cac010e --- /dev/null +++ b/src/period/Period.ts @@ -0,0 +1,5 @@ +export type Period = { + start: Date; + /** Exclusive */ + end: Date; +}; diff --git a/src/period/arePeriodsOverlaping.ts b/src/period/arePeriodsOverlaping.ts new file mode 100644 index 0000000..9fb95e7 --- /dev/null +++ b/src/period/arePeriodsOverlaping.ts @@ -0,0 +1,12 @@ +import { isPeriodContaining } from "./isPeriodContaining"; +import { Period } from "./Period"; + +export function arePeriodsOverlaping( + period1: Period, + period2: Period +): boolean { + return ( + isPeriodContaining(period1, period2.start) || + isPeriodContaining(period2, period1.start) + ); +} diff --git a/src/period/generateConsecutiveIdentifiedPeriods.ts b/src/period/generateConsecutiveIdentifiedPeriods.ts new file mode 100644 index 0000000..98ccea9 --- /dev/null +++ b/src/period/generateConsecutiveIdentifiedPeriods.ts @@ -0,0 +1,27 @@ +import generateConsecutivePeriods from "./generateConsecutivePeriods"; +import { IdentifiedPeriod } from "./IdentifiedPeriod"; +import { Period } from "./Period"; + +export function generateConsecutiveIdentifiedPeriods({ + start, + nextStart, + end = new Date(Date.now()), + idProvider, +}: { + start: Date; + nextStart: (date: Date) => Date; + end?: Date; + idProvider: (period: Period, index: number) => string; +}): IdentifiedPeriod[] { + return generateConsecutivePeriods({ + start, + incrementStart: nextStart, + end, + }).map((period, index) => { + const id = idProvider(period, index); + return { + id, + ...period, + }; + }); +} diff --git a/src/period/generateConsecutivePeriods.test.ts b/src/period/generateConsecutivePeriods.test.ts new file mode 100644 index 0000000..66869d1 --- /dev/null +++ b/src/period/generateConsecutivePeriods.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "@jest/globals"; +import { addYears } from "date-fns/fp"; +import generateConsecutivePeriods from "./generateConsecutivePeriods"; + +describe("generateConsecutivePeriods", () => { + test("generateYears", () => { + const results = generateConsecutivePeriods({ + start: new Date(Date.UTC(2020, 0, 1)), + end: new Date(Date.UTC(2024, 3, 1)), + incrementStart: addYears(1), + }); + + expect(results).toHaveLength(5); + + expect(results[0]).toEqual({ + start: new Date(Date.UTC(2020, 0, 1)), + end: new Date(Date.UTC(2021, 0, 1)), + }); + expect(results[4]).toEqual({ + start: new Date(Date.UTC(2024, 0, 1)), + end: new Date(Date.UTC(2025, 0, 1)), + }); + }); +}); diff --git a/src/period/generateConsecutivePeriods.ts b/src/period/generateConsecutivePeriods.ts new file mode 100644 index 0000000..010749b --- /dev/null +++ b/src/period/generateConsecutivePeriods.ts @@ -0,0 +1,30 @@ +import { Period } from "./Period"; + +export default function generateConsecutivePeriods({ + start, + incrementStart, + end = new Date(Date.now()), +}: { + start: Date; + incrementStart: (date: Date) => Date; + end?: Date; +}): Period[] { + const periods: Period[] = []; + if (start.getTime() < end.getTime()) { + let nextPeriodStart = start; + do { + const periodStart = nextPeriodStart; + const periodEnd = incrementStart(nextPeriodStart); + if (periodEnd.getTime() < periodStart.getTime()) { + throw new Error("incrementStart call decremented date!!!"); + } + periods.push({ + start: periodStart, + end: periodEnd, + }); + + nextPeriodStart = periodEnd; + } while (nextPeriodStart.getTime() < end.getTime()); + } + return periods; +} diff --git a/src/period/isPeriodContaining.test.ts b/src/period/isPeriodContaining.test.ts new file mode 100644 index 0000000..659ff44 --- /dev/null +++ b/src/period/isPeriodContaining.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "@jest/globals"; +import { isPeriodContaining } from "./isPeriodContaining"; + +describe("isPeriodContaining", () => { + test("period contains start date", () => { + expect( + isPeriodContaining( + { + start: new Date(Date.UTC(2024, 0, 1)), + end: new Date(Date.UTC(2024, 3, 1)), + }, + new Date(Date.UTC(2024, 0, 1)) + ) + ).toBe(true); + }); + test("period contains date in period", () => { + expect( + isPeriodContaining( + { + start: new Date(Date.UTC(2024, 0, 1)), + end: new Date(Date.UTC(2024, 3, 1)), + }, + new Date(Date.UTC(2024, 0, 10)) + ) + ).toBe(true); + }); + + test("period does not contain end date", () => { + expect( + isPeriodContaining( + { + start: new Date(Date.UTC(2024, 0, 1)), + end: new Date(Date.UTC(2024, 3, 1)), + }, + new Date(Date.UTC(2024, 3, 1)) + ) + ).toBe(false); + }); + test("period does not contain date before", () => { + expect( + isPeriodContaining( + { + start: new Date(Date.UTC(2024, 0, 1)), + end: new Date(Date.UTC(2024, 3, 1)), + }, + new Date(Date.UTC(2023, 3, 1)) + ) + ).toBe(false); + }); + + test("period does not contain date after", () => { + expect( + isPeriodContaining( + { + start: new Date(Date.UTC(2024, 0, 1)), + end: new Date(Date.UTC(2024, 3, 1)), + }, + new Date(Date.UTC(2025, 3, 1)) + ) + ).toBe(false); + }); +}); diff --git a/src/period/isPeriodContaining.ts b/src/period/isPeriodContaining.ts new file mode 100644 index 0000000..57df233 --- /dev/null +++ b/src/period/isPeriodContaining.ts @@ -0,0 +1,18 @@ +import { Period } from "./Period"; + +export function isPeriodContaining( + period: Period, + dateOrPeriod: Date | Period +): boolean { + if (dateOrPeriod instanceof Date) { + return ( + period.start.getTime() <= dateOrPeriod.getTime() && + dateOrPeriod.getTime() < period.end.getTime() + ); + } else { + return ( + period.start.getTime() >= dateOrPeriod.start.getTime() && + dateOrPeriod.end.getTime() <= period.end.getTime() + ); + } +} diff --git a/src/statistiques/ELStats.ts b/src/statistiques/ELStats.ts new file mode 100644 index 0000000..593968b --- /dev/null +++ b/src/statistiques/ELStats.ts @@ -0,0 +1,21 @@ +export type ELStats = { + /** Current Resistants Count */ + resistantsCount: number; + /** Includes Ancient resistants */ + resistantsOrExCount: number; + + /** + * Resistant count per Year + * Family Partially resistant over a year are counted in. + */ + years: ELPeriodStats[]; + + months: ELPeriodStats[]; +}; + +export type ELPeriodStats = { + periodId: string; + resistantsCount: number; + resistantsCountEvol: number; + resistantsCountEvolPercent: number; +}; diff --git a/src/statistiques/computeELPeriodStats.ts b/src/statistiques/computeELPeriodStats.ts new file mode 100644 index 0000000..6037c03 --- /dev/null +++ b/src/statistiques/computeELPeriodStats.ts @@ -0,0 +1,41 @@ +import { Family, isResistantOverPeriod } from "../data/Family"; +import { IdentifiedPeriod } from "../period/IdentifiedPeriod"; +import { ELPeriodStats } from "./ELStats"; + +export function computeELPeriodStats( + familles: Family[], + periods: IdentifiedPeriod[] +): ELPeriodStats[] { + const periodStats: ELPeriodStats[] = []; + let previousELPeriodStats: ELPeriodStats | null = null; + for (const period of periods) { + const resistantsCount = familles.filter((famille) => + isResistantOverPeriod(famille, period) + ).length; + + const stats: ELPeriodStats = { + periodId: period.id, + resistantsCount, + resistantsCountEvol: evol( + resistantsCount, + previousELPeriodStats?.resistantsCount + ), + resistantsCountEvolPercent: evolPercent( + resistantsCount, + previousELPeriodStats?.resistantsCount + ), + }; + periodStats.push(stats); + previousELPeriodStats = stats; + } + return periodStats; +} + +function evolPercent(current: number, previous: number | undefined): number { + if (previous === undefined) return NaN; + return (100 * evol(current, previous)) / previous; +} +function evol(current: number, previous: number | undefined): number { + if (previous === undefined) return NaN; + return current - previous; +} diff --git a/src/statistiques/computeELStats.ts b/src/statistiques/computeELStats.ts new file mode 100644 index 0000000..4dd542a --- /dev/null +++ b/src/statistiques/computeELStats.ts @@ -0,0 +1,25 @@ +import { Family, isExResistant, isResistant } from "../data/Family"; +import { ELStats } from "./ELStats"; +import { computeELPeriodStats } from "./computeELPeriodStats"; +import { generateELMonths } from "./generateELMonths"; +import { generateELYears } from "./generateELYears"; + +export function computeELStats(families: Family[]): ELStats { + const resistantsCount = families.filter(isResistant).length; + const resistantsOrExCount = families.filter( + (f) => isResistant(f) || isExResistant(f) + ).length; + + const elYears = generateELYears(); + const yearsStats = computeELPeriodStats(families, elYears); + + const months = generateELMonths(); + const monthsStats = computeELPeriodStats(families, months); + + return { + resistantsCount, + resistantsOrExCount, + years: yearsStats, + months: monthsStats, + }; +} diff --git a/src/statistiques/generateELMonths.ts b/src/statistiques/generateELMonths.ts new file mode 100644 index 0000000..8cfdff5 --- /dev/null +++ b/src/statistiques/generateELMonths.ts @@ -0,0 +1,29 @@ +import { getMonth, getYear, setMonth, setYear } from "date-fns"; +import { IdentifiedPeriod } from "../period/IdentifiedPeriod"; +import { generateConsecutiveIdentifiedPeriods } from "../period/generateConsecutiveIdentifiedPeriods"; + +export function generateELMonths(): IdentifiedPeriod[] { + const months = generateConsecutiveIdentifiedPeriods({ + // Start on 2022-07-01 see https://www.notion.so/Liste-de-v-ux-Papa-No-l-de-la-statistique-EL-0f1894c8b16a4fb39b9a759c7446b24b?d=72da4dd0897b4140bb9254e1956d5c6f&pvs=4#a1409542e9974ba2add6e6fe58a70f06 + start: new Date(Date.UTC(2022, 4, 1)), + nextStart: (date) => { + // Don't use addMonth because it has an issue + // (see https://github.com/date-fns/date-fns/issues/571) + const month = getMonth(date); + if (month === 11) { + return setYear(setMonth(date, 0), getYear(date) + 1); + } else { + return setMonth(date, month + 1); + } + }, + end: new Date(Date.now()), + idProvider: (period) => + period.start.getFullYear() + + "-" + + (period.start.getMonth() + 1).toLocaleString("en-US", { + minimumIntegerDigits: 2, + useGrouping: false, + }), + }); + return months; +} diff --git a/src/statistiques/generateELYears.ts b/src/statistiques/generateELYears.ts new file mode 100644 index 0000000..259a703 --- /dev/null +++ b/src/statistiques/generateELYears.ts @@ -0,0 +1,14 @@ +import { addYears } from "date-fns/fp"; +import { IdentifiedPeriod } from "../period/IdentifiedPeriod"; +import { generateConsecutiveIdentifiedPeriods } from "../period/generateConsecutiveIdentifiedPeriods"; + +export function generateELYears(): IdentifiedPeriod[] { + return generateConsecutiveIdentifiedPeriods({ + // Start on 2022-07-01 see https://www.notion.so/Liste-de-v-ux-Papa-No-l-de-la-statistique-EL-0f1894c8b16a4fb39b9a759c7446b24b?d=72da4dd0897b4140bb9254e1956d5c6f&pvs=4#a1409542e9974ba2add6e6fe58a70f06 + start: new Date(Date.UTC(2022, 6, 1)), + nextStart: addYears(1), + end: new Date(Date.now()), + idProvider: (period) => + period.start.getFullYear() + "-" + period.end.getFullYear(), + }); +}