Switch data management to Dexie, add expression promotion/demotion

This commit is contained in:
Thiago Chaves 2022-07-15 20:54:12 +03:00
parent 92f6efa772
commit ae30e8d4a8
24 changed files with 412 additions and 163 deletions

View File

@ -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"

View File

@ -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]) || []
);
}

View File

@ -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,
};
}

View File

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

View File

@ -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]
);
}

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
import { useLiveQuery } from "dexie-react-hooks";
import { database, IndexedExpressionSet } from "../model";
export function useExpressionSets() {
return useLiveQuery(() => database.expression_sets.toArray()) || [];
}

View File

@ -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]) || []
);
}

View File

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

View File

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

View File

@ -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,
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,

View File

@ -1,7 +0,0 @@
export type CategoryId = number;
export type Category = {
id: CategoryId;
name: string;
description: string;
};

View File

@ -1,7 +0,0 @@
export type ExpressionId = number;
export type Expression = {
id: ExpressionId;
prompt: string;
description: string;
};

View File

@ -1,7 +0,0 @@
export type ExpressionSetId = number;
export type ExpressionSet = {
id: ExpressionSetId;
name: string;
description: string;
};

View File

@ -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;
};

174
src/model/database.ts Normal file
View File

@ -0,0 +1,174 @@
import Dexie, { Table } from "dexie";
import { MockData } from "../mock";
import {
Category,
Expression,
ExpressionSet,
ExpressionToCategory,
ExpressionToExpressionSet,
} from "./types";
type WithId<T> = T & { id?: number };
export type IndexedExpression = WithId<Expression>;
export type IndexedExpressionSet = WithId<ExpressionSet>;
export type IndexedCategory = WithId<Category>;
class Database extends Dexie {
expressions!: Table<IndexedExpression, number>;
expression_sets!: Table<IndexedExpressionSet, number>;
categories!: Table<IndexedCategory, number>;
expression_to_expression_set!: Table<ExpressionToExpressionSet>;
expression_to_category!: Table<ExpressionToCategory>;
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 }))
);
});
}
);
}

View File

@ -1,4 +1,2 @@
export * from "./Category";
export * from "./Expression";
export * from "./ExpressionSet";
export * from "./Relationships";
export * from "./types";
export * from "./database";

24
src/model/types.ts Normal file
View File

@ -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;
};

View File

@ -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 <Component {...pageProps} />;
}
export default MyApp;

View File

@ -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 = () => {
<Page>
<div className="page-with-padding scroll">
<ExpressionSetInfo
id={expression_set.id}
id={expression_set.id!}
name={expression_set.name}
description={expression_set.description}
expression_count={expressions.length}
@ -36,7 +39,7 @@ const ExpressionSetDetailsPage: NextPage = () => {
<div className="page-with-bottom-navigation">
<section className="padding-small scroll">
<ExpressionSetInfo
id={expression_set.id}
id={expression_set.id!}
name={expression_set.name}
description={expression_set.description}
expression_count={expressions.length}
@ -60,4 +63,6 @@ const ExpressionSetDetailsPage: NextPage = () => {
);
};
export default ExpressionSetDetailsPage;
export default dynamic(() => Promise.resolve(ExpressionSetDetailsPage), {
ssr: false,
});

View File

@ -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 <PageWithError message="No expression sets found" />;
}
return (
<Page>
@ -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 (
<Link
href={{
@ -46,4 +52,6 @@ function ExpressionSetLink({ id, description, name }: ExpressionSet) {
);
}
export default ExpressionSetListPage;
export default dynamic(() => Promise.resolve(ExpressionSetListPage), {
ssr: false,
});

View File

@ -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 <PageWithError message="Expression set not found" />;
}
if (!expression) {
return <PageWithError message="No expressions left in this set" />;
}
return (
<ExpressionCardPracticeView
key={expression.id}
@ -33,8 +52,8 @@ const ExpressionPracticePage: NextPage = () => {
};
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 (
<Page>
@ -58,16 +76,8 @@ function ExpressionCardPracticeView({
<section className="navigation-bottom">
{revealed ? (
<>
<Link href={{ pathname, query }} passHref>
<a className="navigation-item bottom text-navigation grow">
<div>Wrong</div>
</a>
</Link>
<Link href={{ pathname, query }} passHref>
<a className="navigation-item bottom text-navigation grow">
<div>Right</div>
</a>
</Link>
<DemoteExpressionButton expression_id={expression.id!} />
<PromoteExpressionButton expression_id={expression.id!} />
</>
) : (
<button
@ -83,4 +93,63 @@ function ExpressionCardPracticeView({
);
}
export default ExpressionPracticePage;
interface ExpressionIdProps {
expression_id: number;
}
function PromoteExpressionButton({ expression_id }: ExpressionIdProps) {
const expression_set_id = useExpressionSetQueryId();
const handleClick = useCallback(() => {
assignExpressionToSet({
expression_id,
expression_set_id: expression_set_id + 1,
});
}, [expression_id, expression_set_id]);
return (
<button
className="navigation-item bottom text-navigation grow"
onClick={handleClick}
>
<span>Right</span>
</button>
);
}
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 (
<button
className="navigation-item bottom text-navigation grow"
onClick={handleClick}
>
<span>Wrong</span>
</button>
);
}
export default dynamic(() => Promise.resolve(ExpressionPracticePage), {
ssr: false,
});

13
src/util/clamp.ts Normal file
View File

@ -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;
}

View File

@ -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"