diff --git a/package.json b/package.json index 8f061c4..4fe35a4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "**/*": "prettier --write --ignore-unknown" }, "dependencies": { + "dexie": "^3.2.2", + "dexie-react-hooks": "^1.1.1", "next": "12.2.0", "react": "18.2.0", "react-dom": "18.2.0" diff --git a/src/hooks/useExpressionCategories.ts b/src/hooks/useExpressionCategories.ts index 2cbb47b..8319772 100644 --- a/src/hooks/useExpressionCategories.ts +++ b/src/hooks/useExpressionCategories.ts @@ -1,12 +1,16 @@ -import { Category, Expression, ExpressionId } from "../model"; -import { useExpressionData } from "./useExpressionData"; +import { useLiveQuery } from "dexie-react-hooks"; +import { database } from "../model"; -export function useExpressionCategories( - expression_id?: ExpressionId | undefined -): Category[] { - const { categories, expression_to_category } = useExpressionData(); - const category_ids = expression_to_category - .filter((item) => item.expression_id === expression_id) - .map((item) => item.category_id); - return categories.filter((item) => category_ids.includes(item.id)); +export function useExpressionCategories(expression_id: number) { + return ( + useLiveQuery(() => { + return database.expression_to_category + .where({ expression_id }) + .toArray() + .then((relationships) => { + const category_ids = relationships.map((item) => item.category_id); + return database.categories.where("id").anyOf(category_ids).toArray(); + }); + }, [expression_id]) || [] + ); } diff --git a/src/hooks/useExpressionData.ts b/src/hooks/useExpressionData.ts deleted file mode 100644 index 4bae751..0000000 --- a/src/hooks/useExpressionData.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useState } from "react"; -import { MockData } from "../mock"; -import { - Category, - Expression, - ExpressionSet, - ExpressionToCategory, - ExpressionToExpressionSet, -} from "../model"; - -export interface ExpressionData { - categories: Category[]; - expressions: Expression[]; - expression_sets: ExpressionSet[]; - expression_to_category: ExpressionToCategory[]; - expression_to_expression_set: ExpressionToExpressionSet[]; -} - -export function useExpressionData(): ExpressionData { - const [state] = useState(MockData); - - return { - categories: state.categories, - expressions: state.expressions, - expression_sets: state.expression_sets, - expression_to_category: state.expression_to_category, - expression_to_expression_set: state.expression_to_expression_set, - }; -} diff --git a/src/hooks/useExpressionFilterQueryIds.ts b/src/hooks/useExpressionFilterQueryIds.ts new file mode 100644 index 0000000..0e6301a --- /dev/null +++ b/src/hooks/useExpressionFilterQueryIds.ts @@ -0,0 +1,7 @@ +import { useRouter } from "next/router"; + +export function useExpressionFilterQueryIds(): number[] { + const { query } = useRouter(); + const filter_ids = (query["filter-ids"] || "") as string; + return filter_ids.split(" ").map((id) => Number.parseInt(id)); +} diff --git a/src/hooks/useExpressionSet.ts b/src/hooks/useExpressionSet.ts new file mode 100644 index 0000000..442600f --- /dev/null +++ b/src/hooks/useExpressionSet.ts @@ -0,0 +1,9 @@ +import { useLiveQuery } from "dexie-react-hooks"; +import { database } from "../model"; + +export function useExpressionSet(id: number) { + return useLiveQuery( + () => database.expression_sets.where({ id }).first(), + [id] + ); +} diff --git a/src/hooks/useExpressionSetQueryId.ts b/src/hooks/useExpressionSetQueryId.ts new file mode 100644 index 0000000..19fa106 --- /dev/null +++ b/src/hooks/useExpressionSetQueryId.ts @@ -0,0 +1,6 @@ +import { useRouter } from "next/router"; + +export function useExpressionSetQueryId(): number { + const { query } = useRouter(); + return Number.parseInt(query["set-id"] as string) || 0; +} diff --git a/src/hooks/useExpressionSets.ts b/src/hooks/useExpressionSets.ts new file mode 100644 index 0000000..b8aa79a --- /dev/null +++ b/src/hooks/useExpressionSets.ts @@ -0,0 +1,6 @@ +import { useLiveQuery } from "dexie-react-hooks"; +import { database, IndexedExpressionSet } from "../model"; + +export function useExpressionSets() { + return useLiveQuery(() => database.expression_sets.toArray()) || []; +} diff --git a/src/hooks/useExpressionsInSet.ts b/src/hooks/useExpressionsInSet.ts index c10dc8d..371acfc 100644 --- a/src/hooks/useExpressionsInSet.ts +++ b/src/hooks/useExpressionsInSet.ts @@ -1,12 +1,21 @@ -import { Expression, ExpressionSetId } from "../model"; -import { useExpressionData } from "./useExpressionData"; +import { useLiveQuery } from "dexie-react-hooks"; +import { database } from "../model"; -export function useExpressionsInSet( - expression_set_id: ExpressionSetId | undefined -): Expression[] { - const { expressions, expression_to_expression_set } = useExpressionData(); - const expression_ids = expression_to_expression_set - .filter((item) => item.expression_set_id === expression_set_id) - .map((item) => item.expression_id); - return expressions.filter((item) => expression_ids.includes(item.id)); +export function useExpressionsInSet(expression_set_id: number) { + return ( + useLiveQuery(() => { + return database.expression_to_expression_set + .where({ expression_set_id }) + .toArray() + .then((relationships) => { + const expression_ids = relationships.map( + (item) => item.expression_id + ); + return database.expressions + .where("id") + .anyOf(expression_ids) + .toArray(); + }); + }, [expression_set_id]) || [] + ); } diff --git a/src/hooks/useQueriedExpressionSet.ts b/src/hooks/useQueriedExpressionSet.ts deleted file mode 100644 index 0f2f923..0000000 --- a/src/hooks/useQueriedExpressionSet.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useRouter } from "next/router"; -import { ExpressionSet } from "../model"; -import { useExpressionData } from "./useExpressionData"; - -export function useQueriedExpressionSet(): ExpressionSet | undefined { - const { query } = useRouter(); - const { expression_sets } = useExpressionData(); - const expression_set_id = Number.parseInt(query["set-id"] as string); - - if (!Number.isInteger(expression_set_id)) { - return undefined; - } - return expression_sets.find((item) => item.id === expression_set_id); -} diff --git a/src/hooks/useRandomExpressionInSet.ts b/src/hooks/useRandomExpressionInSet.ts deleted file mode 100644 index 2c8df15..0000000 --- a/src/hooks/useRandomExpressionInSet.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Expression, ExpressionSetId } from "../model"; -import { sample } from "../util/array-utils"; -import { useExpressionsInSet } from "./useExpressionsInSet"; - -export function useRandomExpressionInSet( - expression_set_id: ExpressionSetId | undefined -): Expression | undefined { - const expressions = useExpressionsInSet(expression_set_id); - if (expressions.length === 0) return undefined; - return sample(expressions); -} diff --git a/src/mock/mock-data.ts b/src/mock/mock-data.ts index 68d9300..24d6073 100644 --- a/src/mock/mock-data.ts +++ b/src/mock/mock-data.ts @@ -56,25 +56,20 @@ export function parseRawData( const category_names = new Set( raw_expression_data.map((item) => item.category) ); - const categories: Category[] = Array.from(category_names).map( - (name, index) => ({ - id: index + 1, - name, - description: name, - }) - ); + const categories: Category[] = Array.from(category_names).map((name) => ({ + name, + description: name, + })); const expressions: Expression[] = raw_expression_data.map( - ({ prompt, description }, index) => ({ - id: index + 1, + ({ prompt, description }) => ({ prompt, description, }) ); const expression_sets: ExpressionSet[] = raw_expression_set_data.map( - ({ name, description }, index) => ({ - id: index + 1, + ({ name, description }) => ({ name, description, }) @@ -104,7 +99,7 @@ function matchExpressionAndCategory( { category }: RawExpressionDataItem, categories: Category[] ): ExpressionToCategory { - const category_id = categories.find(({ name }) => name === category)?.id || 0; + const category_id = categories.findIndex(({ name }) => name === category) + 1; return { category_id, expression_id, @@ -117,7 +112,7 @@ function matchExpressionAndExpressionSet( expression_sets: ExpressionSet[] ): ExpressionToExpressionSet { const expression_set_id = - expression_sets.find(({ name }) => name === expression_set)?.id || 0; + expression_sets.findIndex(({ name }) => name === expression_set) + 1; return { expression_id, expression_set_id, diff --git a/src/model/Category.ts b/src/model/Category.ts deleted file mode 100644 index 3763809..0000000 --- a/src/model/Category.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type CategoryId = number; - -export type Category = { - id: CategoryId; - name: string; - description: string; -}; diff --git a/src/model/Expression.ts b/src/model/Expression.ts deleted file mode 100644 index e825318..0000000 --- a/src/model/Expression.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type ExpressionId = number; - -export type Expression = { - id: ExpressionId; - prompt: string; - description: string; -}; diff --git a/src/model/ExpressionSet.ts b/src/model/ExpressionSet.ts deleted file mode 100644 index be28790..0000000 --- a/src/model/ExpressionSet.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type ExpressionSetId = number; - -export type ExpressionSet = { - id: ExpressionSetId; - name: string; - description: string; -}; diff --git a/src/model/Relationships.ts b/src/model/Relationships.ts deleted file mode 100644 index 8f21176..0000000 --- a/src/model/Relationships.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CategoryId } from "./Category"; -import { ExpressionId } from "./Expression"; -import { ExpressionSetId } from "./ExpressionSet"; - -export type ExpressionToCategory = { - expression_id: ExpressionId; - category_id: CategoryId; -}; - -export type ExpressionToExpressionSet = { - expression_id: ExpressionId; - expression_set_id: ExpressionSetId; -}; diff --git a/src/model/database.ts b/src/model/database.ts new file mode 100644 index 0000000..2466a02 --- /dev/null +++ b/src/model/database.ts @@ -0,0 +1,174 @@ +import Dexie, { Table } from "dexie"; +import { MockData } from "../mock"; +import { + Category, + Expression, + ExpressionSet, + ExpressionToCategory, + ExpressionToExpressionSet, +} from "./types"; + +type WithId = T & { id?: number }; + +export type IndexedExpression = WithId; +export type IndexedExpressionSet = WithId; +export type IndexedCategory = WithId; + +class Database extends Dexie { + expressions!: Table; + expression_sets!: Table; + categories!: Table; + expression_to_expression_set!: Table; + expression_to_category!: Table; + + constructor() { + super("Database"); + this.version(1).stores({ + expressions: "++id, prompt, description", + expression_sets: "++id, name, description", + categories: "++id, name, description", + expression_to_expression_set: "expression_id, expression_set_id", + expression_to_category: "expression_id, category_id", + }); + } +} + +export const database = new Database(); +database.on("populate", (transaction) => { + const db = transaction as unknown as Database; + const { + expressions, + expression_sets, + categories, + expression_to_expression_set, + expression_to_category, + } = MockData; + db.expressions.bulkAdd(expressions); + db.expression_sets.bulkAdd(expression_sets); + db.categories.bulkAdd(categories); + db.expression_to_expression_set.bulkAdd(expression_to_expression_set); + db.expression_to_category.bulkAdd(expression_to_category); +}); + +// +// Trivial table operations +// + +export async function addExpression(expression: Expression) { + return await database.expressions.add(expression); +} + +export async function addExpressionSet(expression_set: ExpressionSet) { + return await database.expression_sets.add(expression_set); +} + +export async function addCategory(category: Category) { + return await database.categories.add(category); +} + +// +// Deletion operations +// + +export async function removeExpression(expression_id: number) { + return await database.transaction( + "rw", + database.expression_sets, + database.expression_to_category, + database.expression_to_expression_set, + () => { + database.expression_sets.where({ id: expression_id }).delete(); + database.expression_to_category.where({ expression_id }).delete(); + database.expression_to_expression_set.where({ expression_id }).delete(); + } + ); +} + +export async function removeCategory(category_id: number) { + return await database.transaction( + "rw", + database.categories, + database.expression_to_category, + () => { + database.categories.where({ id: category_id }).delete(); + database.expression_to_category.where({ category_id }).delete(); + } + ); +} + +// +// Relationship operations +// + +// (re)assigns an Expression to an expression set +export async function assignExpressionToSet({ + expression_id, + expression_set_id, +}: ExpressionToExpressionSet) { + return await database.transaction( + "rw", + database.expression_to_expression_set, + () => { + database.expression_to_expression_set + .where("expression_id") + .equals(expression_id) + .delete(); + database.expression_to_expression_set.add({ + expression_id, + expression_set_id, + }); + } + ); +} + +// creates an expression-category relationship, prevents duplication +export async function assignCategoryToExpression( + relationship: ExpressionToCategory +) { + const existing = await database.expression_to_category + .where(relationship) + .first(); + if (existing) return existing; + + return await database.expression_to_category.add(relationship); +} + +export async function unassignCategoryToExpression( + relationship: ExpressionToCategory +) { + return await database.expression_to_category.where(relationship).delete(); +} + +// +// Complex utility function to add expression with its relationships function +// + +export interface addExpressionWithRelationshipsParams { + expression: Expression; + expression_set_id: number; + category_ids: number[]; +} + +export async function addExpressionWithRelationships({ + expression, + expression_set_id, + category_ids, +}: addExpressionWithRelationshipsParams) { + return await database.transaction( + "rw", + database.expressions, + database.categories, + database.expression_to_expression_set, + () => { + database.expressions.add(expression).then((expression_id) => { + database.expression_to_expression_set.add({ + expression_id, + expression_set_id, + }); + database.expression_to_category.bulkAdd( + category_ids.map((category_id) => ({ expression_id, category_id })) + ); + }); + } + ); +} diff --git a/src/model/index.ts b/src/model/index.ts index b3395e2..031f1be 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -1,4 +1,2 @@ -export * from "./Category"; -export * from "./Expression"; -export * from "./ExpressionSet"; -export * from "./Relationships"; +export * from "./types"; +export * from "./database"; diff --git a/src/model/types.ts b/src/model/types.ts new file mode 100644 index 0000000..5811dfc --- /dev/null +++ b/src/model/types.ts @@ -0,0 +1,24 @@ +export type Category = { + name: string; + description: string; +}; + +export type Expression = { + prompt: string; + description: string; +}; + +export type ExpressionSet = { + name: string; + description: string; +}; + +export type ExpressionToCategory = { + expression_id: number; + category_id: number; +}; + +export type ExpressionToExpressionSet = { + expression_id: number; + expression_set_id: number; +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index ae2cb5c..bd5af44 100755 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -2,8 +2,6 @@ import "../styles/globals.css"; import "../styles/components.css"; import type { AppProps } from "next/app"; -function MyApp({ Component, pageProps }: AppProps) { +export default function MyApp({ Component, pageProps }: AppProps) { return ; } - -export default MyApp; diff --git a/src/pages/expression-sets/details.tsx b/src/pages/expression-sets/details.tsx index 2774193..586d03b 100644 --- a/src/pages/expression-sets/details.tsx +++ b/src/pages/expression-sets/details.tsx @@ -1,14 +1,17 @@ import type { NextPage } from "next"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { ExpressionSetInfo } from "../../components/ExpressionSetInfo/ExpressionSetInfo"; import { Page } from "../../components/Page"; +import { useExpressionSet } from "../../hooks/useExpressionSet"; +import { useExpressionSetQueryId } from "../../hooks/useExpressionSetQueryId"; import { useExpressionsInSet } from "../../hooks/useExpressionsInSet"; -import { useQueriedExpressionSet } from "../../hooks/useQueriedExpressionSet"; import { PageWithError } from "../../views/PageWithError/PageWithError"; const ExpressionSetDetailsPage: NextPage = () => { - const expression_set = useQueriedExpressionSet(); - const expressions = useExpressionsInSet(expression_set?.id); + const expression_set_id = useExpressionSetQueryId(); + const expression_set = useExpressionSet(expression_set_id); + const expressions = useExpressionsInSet(expression_set_id); // Fallback for expression set not found if (!expression_set) { @@ -21,7 +24,7 @@ const ExpressionSetDetailsPage: NextPage = () => {
{
{ ); }; -export default ExpressionSetDetailsPage; +export default dynamic(() => Promise.resolve(ExpressionSetDetailsPage), { + ssr: false, +}); diff --git a/src/pages/expression-sets/index.tsx b/src/pages/expression-sets/index.tsx index a3723fe..6cb3c3a 100644 --- a/src/pages/expression-sets/index.tsx +++ b/src/pages/expression-sets/index.tsx @@ -1,13 +1,19 @@ import type { NextPage } from "next"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { ExpressionSetCard } from "../../components/ExpressionSetCard"; import { Page } from "../../components/Page"; -import { useExpressionData } from "../../hooks/useExpressionData"; +import { useExpressionSets } from "../../hooks/useExpressionSets"; import { useExpressionsInSet } from "../../hooks/useExpressionsInSet"; -import { ExpressionSet } from "../../model"; +import { IndexedExpressionSet } from "../../model"; +import { PageWithError } from "../../views/PageWithError"; const ExpressionSetListPage: NextPage = () => { - const { expression_sets } = useExpressionData(); + const expression_sets = useExpressionSets(); + + if (!expression_sets?.length) { + return ; + } return ( @@ -25,8 +31,8 @@ const ExpressionSetListPage: NextPage = () => { ); }; -function ExpressionSetLink({ id, description, name }: ExpressionSet) { - const expressions = useExpressionsInSet(id); +function ExpressionSetLink({ id, description, name }: IndexedExpressionSet) { + const expressions = useExpressionsInSet(id!) || []; return ( Promise.resolve(ExpressionSetListPage), { + ssr: false, +}); diff --git a/src/pages/expression-sets/practice.tsx b/src/pages/expression-sets/practice.tsx index 9884310..e24b012 100644 --- a/src/pages/expression-sets/practice.tsx +++ b/src/pages/expression-sets/practice.tsx @@ -1,28 +1,47 @@ import type { NextPage } from "next"; -import Link from "next/link"; +import dynamic from "next/dynamic"; import { useRouter } from "next/router"; -import { useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { ExpressionCard } from "../../components"; import { Page } from "../../components/Page"; import { useExpressionCategories } from "../../hooks/useExpressionCategories"; -import { useQueriedExpressionSet } from "../../hooks/useQueriedExpressionSet"; -import { useRandomExpressionInSet } from "../../hooks/useRandomExpressionInSet"; -import { Category, Expression } from "../../model"; +import { useExpressionFilterQueryIds } from "../../hooks/useExpressionFilterQueryIds"; +import { useExpressionSetQueryId } from "../../hooks/useExpressionSetQueryId"; +import { useExpressionsInSet } from "../../hooks/useExpressionsInSet"; +import { + assignExpressionToSet, + IndexedCategory, + IndexedExpression, +} from "../../model"; +import { sample } from "../../util/array-utils"; import { PageWithError } from "../../views/PageWithError/PageWithError"; // Do random selection here so we don't keep flipping states with interaction const ExpressionPracticePage: NextPage = () => { - const expression_set = useQueriedExpressionSet(); - const expression = useRandomExpressionInSet(expression_set?.id); - const categories = useExpressionCategories(expression?.id); + // Query info + const expression_set_id = useExpressionSetQueryId(); + const filter_ids = useExpressionFilterQueryIds(); + + // Filter out failed expressions and select random expression + const expressions = useExpressionsInSet(expression_set_id).filter( + (expression) => !filter_ids.includes(expression.id!) + ); + const expression: IndexedExpression | undefined = useMemo( + () => (expressions ? sample(expressions) : undefined), + [expressions] + ); + + // Fetch categories that the expression relates to + const categories = useExpressionCategories(expression?.id || 0); // Fallback views for expression set content not found - if (!expression_set) { + if (!expression_set_id) { return ; } if (!expression) { return ; } + return ( { }; interface ExpressionCardPracticeViewProps { - expression: Expression; - categories: Category[]; + expression: IndexedExpression; + categories: IndexedCategory[]; } // Handle internal state here @@ -42,7 +61,6 @@ function ExpressionCardPracticeView({ expression, categories, }: ExpressionCardPracticeViewProps) { - const { query, pathname } = useRouter(); const [revealed, setRevealed] = useState(false); return ( @@ -58,16 +76,8 @@ function ExpressionCardPracticeView({
{revealed ? ( <> - - -
Wrong
-
- - - -
Right
-
- + + ) : ( + ); +} + +function DemoteExpressionButton({ expression_id }: ExpressionIdProps) { + const { query, pathname, push } = useRouter(); + const expression_set_id = useExpressionSetQueryId(); + const handleClick = useCallback(() => { + () => { + if (expression_set_id === 1) { + const filter_ids = query["filter-ids"] + ? `${query["filter-ids"]} ${expression_id}` + : `${expression_id}`; + push({ + pathname, + query: { + ...query, + "filter-ids": filter_ids, + }, + }); + } else { + assignExpressionToSet({ + expression_id, + expression_set_id: Math.max(1, expression_set_id - 1), + }); + } + }; + }, [expression_id, expression_set_id, pathname, query, push]); + return ( + + ); +} + +export default dynamic(() => Promise.resolve(ExpressionPracticePage), { + ssr: false, +}); diff --git a/src/util/clamp.ts b/src/util/clamp.ts new file mode 100644 index 0000000..1de27da --- /dev/null +++ b/src/util/clamp.ts @@ -0,0 +1,13 @@ +export function clamp({ + value, + lower, + upper, +}: { + value: number; + lower: number; + upper: number; +}): number { + if (value > upper) return upper; + if (value < lower) return lower; + return value; +} diff --git a/yarn.lock b/yarn.lock index 879f8fa..1c2d530 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4786,6 +4786,16 @@ detect-port@^1.3.0: address "^1.0.1" debug "^2.6.0" +dexie-react-hooks@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/dexie-react-hooks/-/dexie-react-hooks-1.1.1.tgz#ff405cc89e5d899ddbac5e40d593f83f9a74106a" + integrity sha512-Cam5JP6PxHN564RvWEoe8cqLhosW0O4CAZ9XEVYeGHJBa6KEJlOpd9CUpV3kmU9dm2MrW97/lk7qkf1xpij7gA== + +dexie@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.2.tgz#fa6f2a3c0d6ed0766f8d97a03720056f88fe0e01" + integrity sha512-q5dC3HPmir2DERlX+toCBbHQXW5MsyrFqPFcovkH9N2S/UW/H3H5AWAB6iEOExeraAu+j+zRDG+zg/D7YhH0qg== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"