diff --git a/apps/web/package.json b/apps/web/package.json index c830b9c4..0dbe53f3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,7 +11,10 @@ "wrangler:dev": "wrangler pages dev" }, "dependencies": { + "@bahar/db-operations": "workspace:*", "@bahar/fsrs": "workspace:*", + "@bahar/result": "workspace:*", + "@bahar/search": "workspace:*", "@elysiajs/eden": "^1.4.6", "@hookform/resolvers": "^5.2.2", "@lingui/core": "^5.3.2", diff --git a/apps/web/src/components/errors/ErrorMessage.tsx b/apps/web/src/components/errors/ErrorMessage.tsx index e9136c8a..c4399f72 100644 --- a/apps/web/src/components/errors/ErrorMessage.tsx +++ b/apps/web/src/components/errors/ErrorMessage.tsx @@ -114,7 +114,7 @@ export const ErrorMessage: FC<{ error: Error }> = ({ error }) => { detail={timestamp} /> - {isDisplayError && ( + {isDisplayError && error.cause && ( <> Cause:} diff --git a/apps/web/src/components/features/flashcards/FlashcardDrawer/FlashcardDrawer.tsx b/apps/web/src/components/features/flashcards/FlashcardDrawer/FlashcardDrawer.tsx index 423902ea..ce135dfe 100644 --- a/apps/web/src/components/features/flashcards/FlashcardDrawer/FlashcardDrawer.tsx +++ b/apps/web/src/components/features/flashcards/FlashcardDrawer/FlashcardDrawer.tsx @@ -42,6 +42,8 @@ import { Brain, Sparkles, PartyPopper, Archive } from "lucide-react"; import { GradeOption } from "./GradeOption"; import { GradeFeedback } from "./GradeFeedback"; import { TagBadgesList } from "./TagBadgesList"; +import { formatScheduleOptions } from "./utils"; +import { useDir } from "@/hooks/useDir"; interface FlashcardDrawerProps extends PropsWithChildren { filters?: SelectDeck["filters"]; @@ -141,6 +143,24 @@ export const FlashcardDrawer: FC = ({ const schedulingCards = schedulingData?.schedulingCards; const now = schedulingData?.now ?? new Date(); + const dir = useDir(); + const locale = dir === "rtl" ? "ar-u-nu-arab" : "en"; + + const intervalLabels = useMemo(() => { + if (!schedulingCards) return null; + + return formatScheduleOptions({ + dates: { + [Rating.Again]: schedulingCards[Rating.Again].card.due, + [Rating.Hard]: schedulingCards[Rating.Hard].card.due, + [Rating.Good]: schedulingCards[Rating.Good].card.due, + [Rating.Easy]: schedulingCards[Rating.Easy].card.due, + }, + now, + locale, + }); + }, [schedulingCards, now, locale]); + const executeGrade = useCallback( async (grade: Grade) => { if (!schedulingCards || !currentCard) return; @@ -413,7 +433,7 @@ export const FlashcardDrawer: FC = ({ - {schedulingCards && showAnswer ? ( + {schedulingCards && intervalLabels && showAnswer ? ( = ({ gradeCard(grade)} /> ))} diff --git a/apps/web/src/components/features/flashcards/FlashcardDrawer/GradeOption.tsx b/apps/web/src/components/features/flashcards/FlashcardDrawer/GradeOption.tsx index 51b55387..f6d0dcc8 100644 --- a/apps/web/src/components/features/flashcards/FlashcardDrawer/GradeOption.tsx +++ b/apps/web/src/components/features/flashcards/FlashcardDrawer/GradeOption.tsx @@ -5,8 +5,6 @@ import { Brain, RotateCcw, ThumbsUp, Zap } from "lucide-react"; import { motion } from "motion/react"; import { FC, ReactNode, useMemo } from "react"; import { Rating } from "ts-fsrs"; -import { formatInterval } from "./utils"; -import { useDir } from "@/hooks/useDir"; type ReviewRating = Rating.Again | Rating.Hard | Rating.Good | Rating.Easy; @@ -19,16 +17,14 @@ type GradeOptionConfig = { type GradeOptionProps = { grade: ReviewRating; disabled?: boolean; - now: Date; - due: Date; + intervalLabel: string; onClick: () => void; }; export const GradeOption: FC = ({ grade, onClick, - now, - due, + intervalLabel, disabled = false, }) => { const options = useMemo(() => { @@ -64,9 +60,6 @@ export const GradeOption: FC = ({ return config; }, []); - const dir = useDir(); - const locale = dir === "rtl" ? "ar-u-nu-arab" : "en"; - const { label, icon, borderStyles } = options[grade]; return ( @@ -87,9 +80,7 @@ export const GradeOption: FC = ({ > {icon} {label} - - {formatInterval(due, now, locale)} - + {intervalLabel} ); diff --git a/apps/web/src/components/features/flashcards/FlashcardDrawer/utils.ts b/apps/web/src/components/features/flashcards/FlashcardDrawer/utils.ts index 50f47b41..82f9eeb8 100644 --- a/apps/web/src/components/features/flashcards/FlashcardDrawer/utils.ts +++ b/apps/web/src/components/features/flashcards/FlashcardDrawer/utils.ts @@ -1,18 +1,104 @@ -import { intlFormatDistance } from "date-fns"; +import { intlFormatDistance } from "@/lib/date"; +import { IntlFormatDistanceUnit } from "date-fns"; +import { Rating } from "ts-fsrs"; -/** - * Formats the interval between two dates into a relative time string. - */ -export const formatInterval = (due: Date, now: Date, locale: string) => { - const DAYS_IN_MS = 1000 * 60 * 60 * 24; +type ReviewRating = Rating.Again | Rating.Hard | Rating.Good | Rating.Easy; - const diffMs = due.getTime() - now.getTime(); - const diffDays = Math.round(diffMs / DAYS_IN_MS); - const dueOnSameDay = diffDays < 1; +export const formatInterval = ({ + due, + now, + locale, + unit, +}: { + due: Date; + now: Date; + locale: string; + unit?: IntlFormatDistanceUnit; +}) => { + return intlFormatDistance(due, now, { style: "narrow", locale, unit }); +}; + +const getSmallerUnit = ( + unit: IntlFormatDistanceUnit, +): IntlFormatDistanceUnit => { + switch (unit) { + case "year": + return "month"; + case "month": + return "week"; + case "week": + return "day"; + case "day": + return "hour"; + case "hour": + return "minute"; + case "minute": + return "second"; + default: + return "second"; + } +}; + +type SchedulingDates = Record; + +export const formatScheduleOptions = ({ + dates, + now, + locale, +}: { + dates: SchedulingDates; + now: Date; + locale: string; +}): Record => { + const grades: ReviewRating[] = [ + Rating.Again, + Rating.Hard, + Rating.Good, + Rating.Easy, + ]; + + const results: Record< + ReviewRating, + { label: string; unit: IntlFormatDistanceUnit } + > = {} as Record< + ReviewRating, + { label: string; unit: IntlFormatDistanceUnit } + >; + + for (const grade of grades) { + results[grade] = formatInterval({ due: dates[grade], now, locale }); + } + + for (let i = 0; i < grades.length - 1; i++) { + const current = grades[i]; + const next = grades[i + 1]; + + if ( + results[current].label === results[next].label && + results[current].unit !== "second" + ) { + const smallerUnit = getSmallerUnit(results[current].unit); + + results[current] = formatInterval({ + due: dates[current], + now, + locale, + unit: smallerUnit, + }); - if (dueOnSameDay) { - return intlFormatDistance(due, now, { style: "narrow", locale }); + results[next] = formatInterval({ + due: dates[next], + now, + locale, + unit: smallerUnit, + }); + } } - return intlFormatDistance(due, now, { style: "narrow", locale, unit: "day" }); + return { + [Rating.Again]: results[Rating.Again].label, + [Rating.Hard]: results[Rating.Hard].label, + [Rating.Good]: results[Rating.Good].label, + [Rating.Easy]: results[Rating.Easy].label, + } as Record; }; diff --git a/apps/web/src/components/search/Highlight.tsx b/apps/web/src/components/search/Highlight.tsx index 8ae9bd1e..7fb9e0d9 100644 --- a/apps/web/src/components/search/Highlight.tsx +++ b/apps/web/src/components/search/Highlight.tsx @@ -1,4 +1,4 @@ -import { highlightWithDiacritics } from "@/lib/search"; +import { highlightWithDiacritics } from "@bahar/search"; import { useAtomValue } from "jotai"; import { FC, useMemo, memo } from "react"; import { searchQueryAtom } from "./state"; diff --git a/apps/web/src/lib/date/constants.ts b/apps/web/src/lib/date/constants.ts new file mode 100644 index 00000000..9b02b7e2 --- /dev/null +++ b/apps/web/src/lib/date/constants.ts @@ -0,0 +1,203 @@ +/** + * @module constants + * @summary Useful constants + * @description + * Collection of useful date constants. + * + * The constants could be imported from `date-fns/constants`: + * + * ```ts + * import { maxTime, minTime } from "date-fns/constants"; + * + * function isAllowedTime(time) { + * return time <= maxTime && time >= minTime; + * } + * ``` + */ + +/** + * @constant + * @name DAYS_IN_WEEK + * @summary Days in 1 week. + */ +export const DAYS_IN_WEEK = 7; + +/** + * @constant + * @name DAYS_IN_YEAR + * @summary Days in 1 year. + * + * @description + * How many days in a year. + * + * One years equals 365.2425 days according to the formula: + * + * > Leap year occurs every 4 years, except for years that are divisible by 100 and not divisible by 400. + * > 1 mean year = (365+1/4-1/100+1/400) days = 365.2425 days + */ +export const DAYS_IN_YEAR = 365.2425; + +/** + * @constant + * @name MAX_TIME + * @summary Maximum allowed time. + * + * @example + * import { MAX_TIME } from "date-fns/constants"; + * + * const isValid = 8640000000000001 <= MAX_TIME; + * //=> false + * + * new Date(8640000000000001); + * //=> Invalid Date + */ +export const MAX_TIME = Math.pow(10, 8) * 24 * 60 * 60 * 1000; + +/** + * @constant + * @name MIN_TIME + * @summary Minimum allowed time. + * + * @example + * import { MIN_TIME } from "date-fns/constants"; + * + * const isValid = -8640000000000001 >= MIN_TIME; + * //=> false + * + * new Date(-8640000000000001) + * //=> Invalid Date + */ +export const MIN_TIME = -MAX_TIME; + +/** + * @constant + * @name MILLISECONDS_IN_WEEK + * @summary Milliseconds in 1 week. + */ +export const MILLISECONDS_IN_WEEK = 604800000; + +/** + * @constant + * @name MILLISECONDS_IN_DAY + * @summary Milliseconds in 1 day. + */ +export const MILLISECONDS_IN_DAY = 86400000; + +/** + * @constant + * @name MILLISECONDS_IN_MINUTE + * @summary Milliseconds in 1 minute + */ +export const MILLISECONDS_IN_MINUTE = 60000; + +/** + * @constant + * @name MILLISECONDS_IN_HOUR + * @summary Milliseconds in 1 hour + */ +export const MILLISECONDS_IN_HOUR = 3600000; + +/** + * @constant + * @name MILLISECONDS_IN_SECOND + * @summary Milliseconds in 1 second + */ +export const MILLISECONDS_IN_SECOND = 1000; + +/** + * @constant + * @name MINUTES_IN_YEAR + * @summary Minutes in 1 year. + */ +export const MINUTES_IN_YEAR = 525600; + +/** + * @constant + * @name MINUTES_IN_MONTH + * @summary Minutes in 1 month. + */ +export const MINUTES_IN_MONTH = 43200; + +/** + * @constant + * @name MINUTES_IN_DAY + * @summary Minutes in 1 day. + */ +export const MINUTES_IN_DAY = 1440; + +/** + * @constant + * @name MINUTES_IN_HOUR + * @summary Minutes in 1 hour. + */ +export const MINUTES_IN_HOUR = 60; + +/** + * @constant + * @name MONTHS_IN_QUARTER + * @summary Months in 1 quarter. + */ +export const MONTHS_IN_QUARTER = 3; + +/** + * @constant + * @name MONTHS_IN_YEAR + * @summary Months in 1 year. + */ +export const MONTHS_IN_YEAR = 12; + +/** + * @constant + * @name QUARTERS_IN_YEAR + * @summary Quarters in 1 year + */ +export const QUARTERS_IN_YEAR = 4; + +/** + * @constant + * @name SECONDS_IN_HOUR + * @summary Seconds in 1 hour. + */ +export const SECONDS_IN_HOUR = 3600; + +/** + * @constant + * @name SECONDS_IN_MINUTE + * @summary Seconds in 1 minute. + */ +export const SECONDS_IN_MINUTE = 60; + +/** + * @constant + * @name SECONDS_IN_DAY + * @summary Seconds in 1 day. + */ +export const SECONDS_IN_DAY = SECONDS_IN_HOUR * 24; + +/** + * @constant + * @name SECONDS_IN_WEEK + * @summary Seconds in 1 week. + */ +export const SECONDS_IN_WEEK = SECONDS_IN_DAY * 7; + +/** + * @constant + * @name SECONDS_IN_YEAR + * @summary Seconds in 1 year. + */ +export const SECONDS_IN_YEAR = SECONDS_IN_DAY * DAYS_IN_YEAR; + +/** + * @constant + * @name SECONDS_IN_MONTH + * @summary Seconds in 1 month + */ +export const SECONDS_IN_MONTH = SECONDS_IN_YEAR / 12; + +/** + * @constant + * @name SECONDS_IN_QUARTER + * @summary Seconds in 1 quarter. + */ +export const SECONDS_IN_QUARTER = SECONDS_IN_MONTH * 3; diff --git a/apps/web/src/lib/date/index.ts b/apps/web/src/lib/date/index.ts new file mode 100644 index 00000000..090a3f4f --- /dev/null +++ b/apps/web/src/lib/date/index.ts @@ -0,0 +1,187 @@ +import { + differenceInSeconds, + differenceInMinutes, + differenceInCalendarDays, + differenceInHours, + differenceInCalendarWeeks, + differenceInCalendarMonths, + differenceInCalendarYears, + IntlFormatDistanceOptions, +} from "date-fns"; +import { + SECONDS_IN_MINUTE, + SECONDS_IN_HOUR, + SECONDS_IN_DAY, + SECONDS_IN_WEEK, + SECONDS_IN_MONTH, + SECONDS_IN_QUARTER, + SECONDS_IN_YEAR, +} from "./constants"; + +/** + * @name intlFormatDistance + * @category Common Helpers + * @summary Formats distance between two dates in a human-readable format + * @description + * The function calculates the difference between two dates and formats it as a human-readable string. + * + * The function will pick the most appropriate unit depending on the distance between dates. For example, if the distance is a few hours, it might return `x hours`. If the distance is a few months, it might return `x months`. + * + * You can also specify a unit to force using it regardless of the distance to get a result like `123456 hours`. + * + * See the table below for the unit picking logic: + * + * | Distance between dates | Result (past) | Result (future) | + * | ---------------------- | -------------- | --------------- | + * | 0 seconds | now | now | + * | 1-59 seconds | X seconds ago | in X seconds | + * | 1-59 minutes | X minutes ago | in X minutes | + * | 1-23 hours | X hours ago | in X hours | + * | 1 day | yesterday | tomorrow | + * | 2-6 days | X days ago | in X days | + * | 7 days | last week | next week | + * | 8 days-1 month | X weeks ago | in X weeks | + * | 1 month | last month | next month | + * | 2-3 months | X months ago | in X months | + * | 1 quarter | last quarter | next quarter | + * | 2-3 quarters | X quarters ago | in X quarters | + * | 1 year | last year | next year | + * | 2+ years | X years ago | in X years | + * + * @param laterDate - The date + * @param earlierDate - The date to compare with. + * @param options - An object with options. + * See MDN for details [Locale identification and negotiation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locale_identification_and_negotiation) + * The narrow one could be similar to the short one for some locales. + * + * @returns The distance in words according to language-sensitive relative time formatting. + * + * @throws `date` must not be Invalid Date + * @throws `baseDate` must not be Invalid Date + * @throws `options.unit` must not be invalid Unit + * @throws `options.locale` must not be invalid locale + * @throws `options.localeMatcher` must not be invalid localeMatcher + * @throws `options.numeric` must not be invalid numeric + * @throws `options.style` must not be invalid style + * + * @example + * // What is the distance between the dates when the fist date is after the second? + * intlFormatDistance( + * new Date(1986, 3, 4, 11, 30, 0), + * new Date(1986, 3, 4, 10, 30, 0) + * ) + * //=> 'in 1 hour' + * + * // What is the distance between the dates when the fist date is before the second? + * intlFormatDistance( + * new Date(1986, 3, 4, 10, 30, 0), + * new Date(1986, 3, 4, 11, 30, 0) + * ) + * //=> '1 hour ago' + * + * @example + * // Use the unit option to force the function to output the result in quarters. Without setting it, the example would return "next year" + * intlFormatDistance( + * new Date(1987, 6, 4, 10, 30, 0), + * new Date(1986, 3, 4, 10, 30, 0), + * { unit: 'quarter' } + * ) + * //=> 'in 5 quarters' + * + * @example + * // Use the locale option to get the result in Spanish. Without setting it, the example would return "in 1 hour". + * intlFormatDistance( + * new Date(1986, 3, 4, 11, 30, 0), + * new Date(1986, 3, 4, 10, 30, 0), + * { locale: 'es' } + * ) + * //=> 'dentro de 1 hora' + * + * @example + * // Use the numeric option to force the function to use numeric values. Without setting it, the example would return "tomorrow". + * intlFormatDistance( + * new Date(1986, 3, 5, 11, 30, 0), + * new Date(1986, 3, 4, 11, 30, 0), + * { numeric: 'always' } + * ) + * //=> 'in 1 day' + * + * @example + * // Use the style option to force the function to use short values. Without setting it, the example would return "in 2 years". + * intlFormatDistance( + * new Date(1988, 3, 4, 11, 30, 0), + * new Date(1986, 3, 4, 11, 30, 0), + * { style: 'short' } + * ) + * //=> 'in 2 yr' + */ +export function intlFormatDistance( + laterDate: Date, + earlierDate: Date, + options?: IntlFormatDistanceOptions, +) { + let value: number = 0; + let unit: Intl.RelativeTimeFormatUnit; + + if (!options?.unit) { + // Get the unit based on diffInSeconds calculations if no unit is specified + const diffInSeconds = differenceInSeconds(laterDate, earlierDate); // The smallest unit + + if (Math.abs(diffInSeconds) < SECONDS_IN_MINUTE) { + value = differenceInSeconds(laterDate, earlierDate); + unit = "second"; + } else if (Math.abs(diffInSeconds) < SECONDS_IN_HOUR) { + value = differenceInMinutes(laterDate, earlierDate); + unit = "minute"; + } else if ( + Math.abs(diffInSeconds) < SECONDS_IN_DAY && + Math.abs(differenceInCalendarDays(laterDate, earlierDate)) < 1 + ) { + value = differenceInHours(laterDate, earlierDate); + unit = "hour"; + } else if ( + Math.abs(diffInSeconds) < SECONDS_IN_WEEK && + (value = differenceInCalendarDays(laterDate, earlierDate)) && + Math.abs(value) < 7 + ) { + unit = "day"; + } else if (Math.abs(diffInSeconds) < SECONDS_IN_MONTH) { + value = differenceInCalendarWeeks(laterDate, earlierDate); + unit = "week"; + } else if (Math.abs(diffInSeconds) < SECONDS_IN_QUARTER) { + value = differenceInCalendarMonths(laterDate, earlierDate); + unit = "month"; + } else if (Math.abs(diffInSeconds) < SECONDS_IN_YEAR) { + value = differenceInCalendarYears(laterDate, earlierDate); + unit = "year"; + } else { + value = differenceInCalendarYears(laterDate, earlierDate); + unit = "year"; + } + } else { + // Get the value if unit is specified + unit = options?.unit; + if (unit === "second") { + value = differenceInSeconds(laterDate, earlierDate); + } else if (unit === "minute") { + value = differenceInMinutes(laterDate, earlierDate); + } else if (unit === "hour") { + value = differenceInHours(laterDate, earlierDate); + } else if (unit === "day") { + value = differenceInCalendarDays(laterDate, earlierDate); + } else if (unit === "week") { + value = differenceInCalendarWeeks(laterDate, earlierDate); + } else if (unit === "month") { + value = differenceInCalendarMonths(laterDate, earlierDate); + } else if (unit === "year") { + value = differenceInCalendarYears(laterDate, earlierDate); + } + } + + const rtf = new Intl.RelativeTimeFormat(options?.locale, { + numeric: "auto", + ...options, + }); + + return { label: rtf.format(value, unit), unit, value }; +} diff --git a/apps/web/src/lib/db/export/index.ts b/apps/web/src/lib/db/export/index.ts index 87d3648b..7c537018 100644 --- a/apps/web/src/lib/db/export/index.ts +++ b/apps/web/src/lib/db/export/index.ts @@ -3,11 +3,11 @@ import { RawDictionaryEntry, } from "@bahar/drizzle-user-db-schemas"; import { - convertRawDictionaryEntryToSelectDictionaryEntry, + convertRawDictionaryEntryToSelect, type ConvertDictionaryEntryError, -} from "../utils"; +} from "@bahar/db-operations"; import { ImportWordV1 } from "../import/v1/schema"; -import { ok, err, type Result } from "../../result"; +import { ok, err, type Result } from "@bahar/result"; /** * Transforms a dictionary entry with its flashcards into export format. @@ -22,7 +22,7 @@ export function transformForExport({ flashcards: SelectFlashcard[]; includeFlashcards: boolean; }): Result { - const convertResult = convertRawDictionaryEntryToSelectDictionaryEntry(entry); + const convertResult = convertRawDictionaryEntryToSelect(entry); if (!convertResult.ok) { return err(convertResult.error); } diff --git a/apps/web/src/lib/db/index.ts b/apps/web/src/lib/db/index.ts index 1cb343a7..2e3210d5 100644 --- a/apps/web/src/lib/db/index.ts +++ b/apps/web/src/lib/db/index.ts @@ -1,7 +1,7 @@ import { connect, Database } from "@tursodatabase/sync-wasm/vite"; import { SelectMigration } from "@bahar/drizzle-user-db-schemas"; import { api } from "../api"; -import { err, ok, tryCatch } from "../result"; +import { err, ok, tryCatch } from "@bahar/result"; import * as Sentry from "@sentry/react"; /** diff --git a/apps/web/src/lib/db/operations/dictionary-entries.ts b/apps/web/src/lib/db/operations/dictionary-entries.ts index 43bff5ef..1018dbf3 100644 --- a/apps/web/src/lib/db/operations/dictionary-entries.ts +++ b/apps/web/src/lib/db/operations/dictionary-entries.ts @@ -6,9 +6,9 @@ import { import { ensureDb } from ".."; import { nanoid } from "nanoid"; import { - convertRawDictionaryEntryToSelectDictionaryEntry, + convertRawDictionaryEntryToSelect, type ConvertDictionaryEntryError, -} from "../utils"; +} from "@bahar/db-operations"; import { NullToUndefined } from "../../utils"; import { TableOperation } from "./types"; @@ -34,7 +34,7 @@ export const dictionaryEntriesTable = { throw new Error(`Dictionary entry not found: ${id}`); } - const result = convertRawDictionaryEntryToSelectDictionaryEntry(res); + const result = convertRawDictionaryEntryToSelect(res); if (!result.ok) { throw new DictionaryEntryParseError(result.error); } @@ -129,7 +129,7 @@ export const dictionaryEntriesTable = { ); } - const result = convertRawDictionaryEntryToSelectDictionaryEntry(res); + const result = convertRawDictionaryEntryToSelect(res); if (!result.ok) { throw new DictionaryEntryParseError(result.error); } @@ -243,7 +243,7 @@ export const dictionaryEntriesTable = { throw new Error(`Dictionary entry not found: ${id}`); } - const result = convertRawDictionaryEntryToSelectDictionaryEntry(res); + const result = convertRawDictionaryEntryToSelect(res); if (!result.ok) { throw new DictionaryEntryParseError(result.error); } @@ -278,7 +278,7 @@ export const dictionaryEntriesTable = { .prepare(`DELETE FROM dictionary_entries WHERE id = ?;`) .run([id]); - const result = convertRawDictionaryEntryToSelectDictionaryEntry(res); + const result = convertRawDictionaryEntryToSelect(res); if (!result.ok) { throw new DictionaryEntryParseError(result.error); } diff --git a/apps/web/src/lib/db/operations/flashcards.ts b/apps/web/src/lib/db/operations/flashcards.ts index 6b741b71..a1fad97b 100644 --- a/apps/web/src/lib/db/operations/flashcards.ts +++ b/apps/web/src/lib/db/operations/flashcards.ts @@ -11,9 +11,9 @@ import { ensureDb } from ".."; import { nanoid } from "nanoid"; import { buildSelectWithNestedJson, - convertRawDictionaryEntryToSelectDictionaryEntry, + convertRawDictionaryEntryToSelect, DICTIONARY_ENTRY_COLUMNS, -} from "../utils"; +} from "@bahar/db-operations"; import { TableOperation } from "./types"; import * as Sentry from "@sentry/react"; import { fsrs, Rating, Card } from "ts-fsrs"; @@ -130,7 +130,7 @@ export const flashcardsTable = { return rawResults ?.map((raw) => { - const result = convertRawDictionaryEntryToSelectDictionaryEntry( + const result = convertRawDictionaryEntryToSelect( JSON.parse(raw.dictionary_entry), ); diff --git a/apps/web/src/lib/db/utils.ts b/apps/web/src/lib/db/utils.ts deleted file mode 100644 index e4ffebd3..00000000 --- a/apps/web/src/lib/db/utils.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { - RawDictionaryEntry, - SelectDictionaryEntry, - RootLettersSchema, - TagsSchema, - AntonymSchema, - ExampleSchema, - MorphologySchema, -} from "@bahar/drizzle-user-db-schemas"; -import { safeJsonParse, ok, err, type Result } from "../result"; -import { z } from "zod"; - -export type ConvertDictionaryEntryError = { - entryId: string; - word: string; - field: string; - reason: string; -}; - -/** - * Generates a SQL json_object clause from type-safe column names. - * Extracts all keys from a type definition and creates key-value pairs for json_object(). - * - * @example - * // Input - * columns = ['id', 'word', 'translation', 'tags'] - * tableAlias = 'd' - * jsonObjectAlias = 'dictionary_entry' - * - * // Output - * "json_object('id', d.id, 'word', d.word, 'translation', d.translation, 'tags', d.tags) as dictionary_entry" - * - * // Used in query as: - * "SELECT f.*, json_object('id', d.id, 'word', d.word, ...) as dictionary_entry FROM flashcards f LEFT JOIN dictionary_entries d ON ..." - */ -export const buildSelectWithNestedJson = ({ - columns, - tableAlias, - jsonObjectAlias, -}: { - columns: string[]; - tableAlias: string; - jsonObjectAlias: string; -}): string => { - const jsonColumns = ["root", "tags", "antonyms", "examples", "morphology"]; - - const jsonPairs = columns - .map((col) => { - if (jsonColumns.includes(col)) { - return `'${col}', ${tableAlias}.${col}`; - } else { - // Escape backslashes and quotes in string columns - // This is necessary otherwise we'll get malformed JSON errors - // for any records that have double quotes in them. - return `'${col}', REPLACE(REPLACE(${tableAlias}.${col}, '\\', '\\\\'), '"', '\\"')`; - } - }) - .join(", "); - - return `json_object(${jsonPairs}) as ${jsonObjectAlias}`; -}; - -export const DICTIONARY_ENTRY_COLUMNS = [ - "id", - "created_at", - "created_at_timestamp_ms", - "updated_at", - "updated_at_timestamp_ms", - "word", - "translation", - "definition", - "type", - "root", - "tags", - "antonyms", - "examples", - "morphology", -] satisfies (keyof SelectDictionaryEntry)[]; - -export const convertRawDictionaryEntryToSelectDictionaryEntry = ( - raw: RawDictionaryEntry, -): Result => { - const rootResult = safeJsonParse(raw.root, RootLettersSchema); - if (!rootResult.ok) { - return err({ - entryId: raw.id, - word: raw.word, - field: "root", - reason: JSON.stringify(rootResult.error), - }); - } - - const tagsResult = safeJsonParse(raw.tags, TagsSchema); - if (!tagsResult.ok) { - return err({ - entryId: raw.id, - word: raw.word, - field: "tags", - reason: JSON.stringify(tagsResult.error), - }); - } - - const antonymsResult = safeJsonParse(raw.antonyms, z.array(AntonymSchema)); - if (!antonymsResult.ok) { - return err({ - entryId: raw.id, - word: raw.word, - field: "antonyms", - reason: JSON.stringify(antonymsResult.error), - }); - } - - const examplesResult = safeJsonParse(raw.examples, z.array(ExampleSchema)); - if (!examplesResult.ok) { - return err({ - entryId: raw.id, - word: raw.word, - field: "examples", - reason: JSON.stringify(examplesResult.error), - }); - } - - const morphologyResult = safeJsonParse(raw.morphology, MorphologySchema); - if (!morphologyResult.ok) { - return err({ - entryId: raw.id, - word: raw.word, - field: "morphology", - reason: JSON.stringify(morphologyResult.error), - }); - } - - return ok({ - id: raw.id, - created_at: raw.created_at, - created_at_timestamp_ms: raw.created_at_timestamp_ms, - updated_at: raw.updated_at, - updated_at_timestamp_ms: raw.updated_at_timestamp_ms, - word: raw.word, - translation: raw.translation, - definition: raw.definition, - type: raw.type, - root: rootResult.value, - tags: tagsResult.value, - antonyms: antonymsResult.value, - examples: examplesResult.value, - morphology: morphologyResult.value, - }); -}; diff --git a/apps/web/src/lib/fetch.ts b/apps/web/src/lib/fetch.ts deleted file mode 100644 index 4e36ae16..00000000 --- a/apps/web/src/lib/fetch.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { TRACE_ID_HEADER, generateTraceId } from "./utils"; - -type Fetch = typeof fetch; - -/** - * A wrapper around fetch that adds a trace ID header to all requests. - */ -export const tracedFetch: Fetch = async (input, init) => { - const actualInit: RequestInit = { - ...init, - // Ensure headers don't get overwritten by spreading them after - headers: { - ...(init?.headers && typeof init.headers === "object" - ? init.headers - : {}), - - [TRACE_ID_HEADER]: generateTraceId(), - }, - }; - - // Return fetch with our modified init object - return fetch(input, actualInit); -}; diff --git a/apps/web/src/lib/result.ts b/apps/web/src/lib/result.ts deleted file mode 100644 index f5527e22..00000000 --- a/apps/web/src/lib/result.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from "zod"; - -/** - * Simple Result type for error handling - */ -export type Result = - | { ok: true; value: T } - | { ok: false; error: E }; - -/** - * Creates a success Result with inferred value type - */ -export const ok = (value: T) => ({ - ok: true as const, - value, -}); - -/** - * Creates an error Result with inferred literal types preserved - */ -export const err = (error: E) => ({ - ok: false as const, - error, -}); - -/** - * Wraps a promise and returns a `Result` with error handling - */ -export const tryCatch = async ( - fn: () => Promise, - mapError: (error: unknown) => E, -): Promise> => { - try { - const value = await fn(); - return ok(value); - } catch (error) { - return err(mapError(error)); - } -}; - -/** - * Safely parses JSON using Zod schema validation - * Returns a Result with null value on parse failure - */ -export const safeJsonParse = ( - json: string | null | undefined, - schema: T, -): Result | null> => { - if (!json) return { ok: true, value: null }; - - try { - const parsed = JSON.parse(json); - const validated = schema.safeParse(parsed); - if (!validated.success) { - return err(validated.error.issues); - } - return ok(validated.data); - } catch (error) { - if (error instanceof Error) { - return err([{ message: error.message }]); - } - - return err([{ message: "Unknown error" }]); - } -}; diff --git a/apps/web/src/lib/search/index.ts b/apps/web/src/lib/search/index.ts index fb7a6450..296e920b 100644 --- a/apps/web/src/lib/search/index.ts +++ b/apps/web/src/lib/search/index.ts @@ -1,4 +1,3 @@ -import { create, insertMultiple } from "@orama/orama"; import { ensureDb } from "../db"; import { RawDictionaryEntry, @@ -8,53 +7,17 @@ import { ExampleSchema, MorphologySchema, } from "@bahar/drizzle-user-db-schemas"; -import { multiLanguageTokenizer } from "./orama-tokenizer"; -import { pluginQPS } from "@orama/plugin-qps"; +import { ok, err } from "@bahar/result"; +import { safeJsonParse } from "@bahar/db-operations"; import { - stripArabicDiacritics, - normalizeArabicHamza, - normalizeArabicWeakLetters, -} from "../utils"; -import { safeJsonParse, ok, err } from "../result"; + createDictionaryDatabase, + insertDocuments, + type DictionaryOrama, +} from "@bahar/search"; import * as Sentry from "@sentry/react"; import { z } from "zod"; -const formatElapsedTime = (number: bigint): string | number | object => { - const ONE_MS_IN_NS = 1_000_000n; - - const numInMs = number / ONE_MS_IN_NS; - - if (numInMs < 1n) { - return { raw: number, formatted: "<1ms" }; - } - - return { - raw: number, - formatted: `${numInMs}ms`, - }; -}; - -const createOramaDb = () => - create({ - schema: { - created_at_timestamp_ms: "number", - updated_at_timestamp_ms: "number", - word: "string", - translation: "string", - definition: "string", - type: "enum", - root: "string[]", - tags: "string[]", - // TODO: add more fields from morphology - }, - plugins: [pluginQPS()], - components: { - tokenizer: multiLanguageTokenizer, - formatElapsedTime, - }, - }); - -let oramaDb = createOramaDb(); +let oramaDb = createDictionaryDatabase(); export const getOramaDb = () => oramaDb; @@ -173,7 +136,11 @@ export const hydrateOramaDb = async () => { .filter((entry) => entry !== null); if (dictionaryEntries.length > 0) { - await insertMultiple(oramaDb, dictionaryEntries, BATCH_SIZE); + await insertDocuments( + oramaDb as DictionaryOrama, + dictionaryEntries, + BATCH_SIZE, + ); } offset += BATCH_SIZE; @@ -203,7 +170,7 @@ export const hydrateOramaDb = async () => { }; export const resetOramaDb = () => { - oramaDb = createOramaDb(); + oramaDb = createDictionaryDatabase(); isOramaHydrated = false; }; @@ -211,7 +178,7 @@ export const resetOramaDb = () => { export const rehydrateOramaDb = async () => { const BATCH_SIZE = 100; const db = await ensureDb(); - const newOramaDb = createOramaDb(); + const newOramaDb = createDictionaryDatabase(); let offset = 0; @@ -248,7 +215,11 @@ export const rehydrateOramaDb = async () => { .filter((entry) => entry !== null); if (dictionaryEntries.length > 0) { - await insertMultiple(newOramaDb, dictionaryEntries, BATCH_SIZE); + await insertDocuments( + newOramaDb as DictionaryOrama, + dictionaryEntries, + BATCH_SIZE, + ); } offset += BATCH_SIZE; @@ -262,122 +233,3 @@ export const rehydrateOramaDb = async () => { }); } }; - -interface HighlightOptions { - HTMLTag?: string; - CSSClass?: string; -} - -const normalizeArabicForMatching = (text: string): string => { - return normalizeArabicWeakLetters( - normalizeArabicHamza(stripArabicDiacritics(text)), - ); -}; - -/** - * Creates a mapping from normalized text positions to original text positions. - * Only diacritics are skipped (not counted as positions). - * Hamza and weak letter normalization preserves position count. - */ -const buildPositionMap = (original: string): number[] => { - const map: number[] = []; - const diacriticsRegex = /[\u064B-\u0652\u0640]/; - - for (let i = 0; i < original.length; i++) { - if (!diacriticsRegex.test(original[i])) { - map.push(i); - } - } - - return map; -}; - -/** - * Escapes special regex characters in a string. - */ -const escapeRegExp = (string: string): string => { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -}; - -/** - * Highlights text matches with Arabic-aware normalization. - * Mimics Meilisearch's behavior: - * - Ignores diacritics (harakat): "كتاب" matches "كِتَابٌ" - * - Normalizes hamza: "هيأة" matches "هيئة" - * - Normalizes weak letters: "عميرة" matches "عمارة" - */ -export const highlightWithDiacritics = ( - text: string, - searchTerm: string, - options: HighlightOptions = {}, -): string => { - const { HTMLTag = "mark", CSSClass = "" } = options; - - if (!searchTerm.trim()) { - return text; - } - - const normalizedText = normalizeArabicForMatching(text); - const normalizedTerm = normalizeArabicForMatching(searchTerm); - const positionMap = buildPositionMap(text); - - // Find all matches in the normalized text (case-insensitive) - const regex = new RegExp(escapeRegExp(normalizedTerm), "gi"); - const matches: Array<{ start: number; end: number }> = []; - - let match; - while ((match = regex.exec(normalizedText)) !== null) { - matches.push({ - start: match.index, - end: match.index + match[0].length, - }); - } - - if (matches.length === 0) { - return text; - } - - // Map normalized positions back to original positions - const originalMatches = matches.map(({ start, end }) => { - const originalStart = positionMap[start]; - // end - 1 because end is exclusive - const lastCharOriginalPos = positionMap[end - 1]; - - // Find the end position including any trailing diacritics - let originalEnd = lastCharOriginalPos + 1; - const diacriticsRegex = /[\u064B-\u0652\u0640]/; - while ( - originalEnd < text.length && - diacriticsRegex.test(text[originalEnd]) - ) { - originalEnd++; - } - - return { start: originalStart, end: originalEnd }; - }); - - // Build the highlighted string - const classAttr = CSSClass ? ` class="${CSSClass}"` : ""; - const openTag = `<${HTMLTag}${classAttr}>`; - const closeTag = ``; - - let result = ""; - let lastEnd = 0; - - for (const { start, end } of originalMatches) { - result += text.slice(lastEnd, start); - result += openTag + text.slice(start, end) + closeTag; - lastEnd = end; - } - - result += text.slice(lastEnd); - - return result; -}; - -if (import.meta.hot) { - import.meta.hot.accept(["./orama-tokenizer"], async () => { - resetOramaDb(); - await hydrateOramaDb(); - }); -} diff --git a/apps/web/src/lib/search/orama-tokenizer.ts b/apps/web/src/lib/search/orama-tokenizer.ts deleted file mode 100644 index 5305fb65..00000000 --- a/apps/web/src/lib/search/orama-tokenizer.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { tokenizer as defaultTokenizer } from "@orama/orama/components"; -import { stemmer as arabicStemmer } from "@orama/stemmers/arabic"; -import { - stripArabicDiacritics, - normalizeArabicHamza, - normalizeArabicWeakLetters, -} from "../utils"; - -export type OramaLanguage = "arabic" | "english"; - -export const arabicTokenizer = defaultTokenizer.createTokenizer({ - language: "arabic", - stemming: true, - stemmer: arabicStemmer, - - // Disabling stop words because words are all added by - // users manually, so each one is meaningful - stopWords: false, - stemmerSkipProperties: ["tags"], -}); - -const englishTokenizer = defaultTokenizer.createTokenizer({ - language: "english", - stemming: true, - stopWords: false, - stemmerSkipProperties: ["tags"], -}); - -/** - * Multi-language tokenizer that delegates to Arabic or English tokenizers - * based on the language parameter - */ -export const multiLanguageTokenizer = { - language: "multi" as const, - normalizationCache: new Map(), - - tokenize(raw: string, language: string, prop?: string): string[] { - const ENGLISH_PROPS = ["translation"]; - - const normalizedRaw = normalizeArabicWeakLetters( - normalizeArabicHamza(stripArabicDiacritics(raw)), - ); - - let result: string[]; - - if (prop && ENGLISH_PROPS.includes(prop)) { - result = englishTokenizer.tokenize(raw, "english", prop); - } else if (prop && !language && !ENGLISH_PROPS.includes(prop)) { - result = arabicTokenizer.tokenize(normalizedRaw, "arabic", prop); - } else if (language === "arabic") { - result = arabicTokenizer.tokenize(normalizedRaw, language, prop); - } else { - result = englishTokenizer.tokenize(raw, language, prop); - } - - return result; - }, -}; diff --git a/apps/web/src/routes/_authorized-layout/_app-layout/settings/route.lazy.tsx b/apps/web/src/routes/_authorized-layout/_app-layout/settings/route.lazy.tsx index 877d6b9d..6d74b1fd 100644 --- a/apps/web/src/routes/_authorized-layout/_app-layout/settings/route.lazy.tsx +++ b/apps/web/src/routes/_authorized-layout/_app-layout/settings/route.lazy.tsx @@ -24,7 +24,6 @@ import { } from "@/components/ui/dialog"; import { useToast } from "@/hooks/useToast"; import { ImportError, parseImportErrors, ImportErrorCode } from "@/lib/error"; -import { tracedFetch } from "@/lib/fetch"; import { useLingui } from "@lingui/react/macro"; import { createLazyFileRoute } from "@tanstack/react-router"; import { useCallback, useState } from "react"; @@ -269,7 +268,9 @@ const Settings = () => { const { version, entries: validatedDictionary } = parsedImport; const BATCH_SIZE = 100; - const batches = [...batchArray(validatedDictionary, BATCH_SIZE)]; + const batches = [ + ...batchArray(validatedDictionary, BATCH_SIZE), + ]; const totalBatches = batches.length; setImportProgress({ current: 0, total: totalBatches }); @@ -280,10 +281,8 @@ const Settings = () => { const insertBatch = db.transaction( async (batch: typeof validatedDictionary) => { for (const word of batch) { - const { dictEntry, flashcards } = createImportStatements( - word, - version, - ); + const { dictEntry, flashcards } = + createImportStatements(word, version); await db.prepare(dictEntry.sql).run(dictEntry.args); await db @@ -385,12 +384,17 @@ const Settings = () => {

- {Math.round((importProgress.current / importProgress.total) * 100)}% + {Math.round( + (importProgress.current / importProgress.total) * 100, + )} + %

)} diff --git a/apps/web/src/routes/_authorized-layout/route.tsx b/apps/web/src/routes/_authorized-layout/route.tsx index 588b0963..a03a5e42 100644 --- a/apps/web/src/routes/_authorized-layout/route.tsx +++ b/apps/web/src/routes/_authorized-layout/route.tsx @@ -230,8 +230,6 @@ export const Route = createFileRoute("/_authorized-layout")({ const error = initDbResult.error; const errReason = "reason" in error ? error.reason : null; - console.log(errReason); - Sentry.captureException(new Error(error.type), { contexts: { db_init: { @@ -261,7 +259,6 @@ export const Route = createFileRoute("/_authorized-layout")({ }); case "token_refresh_failed": - case "turso_remote_sync_failed": case "api_schema_verification_failed": case "db_connection_failed_after_refresh": throw new DisplayError({ @@ -296,6 +293,11 @@ export const Route = createFileRoute("/_authorized-layout")({ hasManualFix: true, }); + // Don't throw on turso_remote_sync_failed error + // since user can still use local db + case "turso_remote_sync_failed": + break; + default: throw new DisplayError({ message: t`There was an unexpected error. Please try again.`, diff --git a/packages/db-operations/package.json b/packages/db-operations/package.json index 733967af..d3dd5be1 100644 --- a/packages/db-operations/package.json +++ b/packages/db-operations/package.json @@ -17,7 +17,6 @@ "zod": "4.0.17" }, "dependencies": { - "nanoid": "^5.0.7", - "react-native-get-random-values": "^2.0.0" + "nanoid": "^5.0.7" } } diff --git a/packages/db-operations/src/utils.ts b/packages/db-operations/src/utils.ts index b2c01b7d..d265e6ac 100644 --- a/packages/db-operations/src/utils.ts +++ b/packages/db-operations/src/utils.ts @@ -2,8 +2,6 @@ * SQL utility functions for database operations. */ -// Polyfill for React Native (must be imported before nanoid) -import "react-native-get-random-values"; import { nanoid } from "nanoid/non-secure"; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5e27287..d46c37be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -407,9 +407,18 @@ importers: apps/web: dependencies: + '@bahar/db-operations': + specifier: workspace:* + version: link:../../packages/db-operations '@bahar/fsrs': specifier: workspace:* version: link:../../packages/fsrs + '@bahar/result': + specifier: workspace:* + version: link:../../packages/result + '@bahar/search': + specifier: workspace:* + version: link:../../packages/search '@elysiajs/eden': specifier: ^1.4.6 version: 1.4.6(elysia@1.4.19) @@ -686,9 +695,6 @@ importers: nanoid: specifier: ^5.0.7 version: 5.0.7 - react-native-get-random-values: - specifier: ^2.0.0 - version: 2.0.0(react-native@0.81.5) devDependencies: '@bahar/drizzle-user-db-schemas': specifier: workspace:* @@ -1650,6 +1656,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.0): resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} @@ -1666,6 +1673,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.0): resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} @@ -1682,6 +1690,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.26.0): resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} @@ -1700,6 +1709,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-decorators@7.25.9(@babel/core@7.26.0): resolution: {integrity: sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg==} @@ -1763,6 +1773,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.3): resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} @@ -1789,6 +1800,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.0): resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} @@ -1805,6 +1817,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0): resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==} @@ -1830,6 +1843,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.0): resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} @@ -1846,6 +1860,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.0): resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} @@ -1862,6 +1877,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.0): resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} @@ -1878,6 +1894,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.0): resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} @@ -1894,6 +1911,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.0): resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} @@ -1910,6 +1928,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.26.0): resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} @@ -1928,6 +1947,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.0): resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} @@ -1946,6 +1966,7 @@ packages: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.26.5 + dev: true /@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.0): resolution: {integrity: sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==} @@ -9117,21 +9138,6 @@ packages: nullthrows: 1.1.1 yargs: 17.7.2 - /@react-native/codegen@0.81.5(@babel/core@7.28.3): - resolution: {integrity: sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==} - engines: {node: '>= 20.19.4'} - peerDependencies: - '@babel/core': '*' - dependencies: - '@babel/core': 7.28.3 - '@babel/parser': 7.28.5 - glob: 7.2.3 - hermes-parser: 0.29.1 - invariant: 2.2.4 - nullthrows: 1.1.1 - yargs: 17.7.2 - dev: false - /@react-native/community-cli-plugin@0.81.5: resolution: {integrity: sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==} engines: {node: '>= 20.19.4'} @@ -9211,23 +9217,6 @@ packages: react: 19.1.0 react-native: 0.81.5(@babel/core@7.26.0)(@types/react@19.1.17)(react@19.1.0) - /@react-native/virtualized-lists@0.81.5(react-native@0.81.5)(react@19.1.0): - resolution: {integrity: sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==} - engines: {node: '>= 20.19.4'} - peerDependencies: - '@types/react': ^19.1.0 - react: '*' - react-native: '*' - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - invariant: 2.2.4 - nullthrows: 1.1.1 - react: 19.1.0 - react-native: 0.81.5(@babel/core@7.28.3)(react@19.1.0) - dev: false - /@react-navigation/bottom-tabs@7.2.0(@react-navigation/native@7.1.22)(react-native-safe-area-context@5.6.2)(react-native-screens@4.16.0)(react-native@0.81.5)(react@19.1.0): resolution: {integrity: sha512-1LxjgnbPyFINyf9Qr5d1YE0pYhuJayg5TCIIFQmbcX4PRhX7FKUXV7cX8OzrKXEdZi/UE/VNXugtozPAR9zgvA==} peerDependencies: @@ -12326,6 +12315,7 @@ packages: slash: 3.0.0 transitivePeerDependencies: - supports-color + dev: true /babel-plugin-istanbul@6.1.1: resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} @@ -12488,6 +12478,7 @@ packages: '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.3) '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.3) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.3) + dev: true /babel-preset-expo@54.0.7(@babel/core@7.26.0)(@babel/runtime@7.28.3)(expo@54.0.25)(react-refresh@0.14.2): resolution: {integrity: sha512-JENWk0bvxW4I1ftveO8GRtX2t2TH6N4Z0TPvIHxroZ/4SswUfyNsUNbbP7Fm4erj3ar/JHGri5kTZ+s3xdjHZw==} @@ -12549,6 +12540,7 @@ packages: '@babel/core': 7.28.3 babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.28.3) + dev: true /bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -15711,10 +15703,6 @@ packages: iconv-lite: 0.4.24 tmp: 0.0.33 - /fast-base64-decode@1.0.0: - resolution: {integrity: sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==} - dev: false - /fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} dev: true @@ -20600,15 +20588,6 @@ packages: react: 19.1.0 react-native: 0.81.5(@babel/core@7.26.0)(@types/react@19.1.17)(react@19.1.0) - /react-native-get-random-values@2.0.0(react-native@0.81.5): - resolution: {integrity: sha512-wx7/aPqsUIiWsG35D+MsUJd8ij96e3JKddklSdrdZUrheTx89gPtz3Q2yl9knBArj5u26Cl23T88ai+Q0vypdQ==} - peerDependencies: - react-native: '>=0.81' - dependencies: - fast-base64-decode: 1.0.0 - react-native: 0.81.5(@babel/core@7.28.3)(react@19.1.0) - dev: false - /react-native-is-edge-to-edge@1.1.7(react-native@0.81.5)(react@19.1.0): resolution: {integrity: sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w==} peerDependencies: @@ -20795,61 +20774,6 @@ packages: - supports-color - utf-8-validate - /react-native@0.81.5(@babel/core@7.28.3)(react@19.1.0): - resolution: {integrity: sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==} - engines: {node: '>= 20.19.4'} - hasBin: true - peerDependencies: - '@types/react': ^19.1.0 - react: ^19.1.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@jest/create-cache-key-function': 29.7.0 - '@react-native/assets-registry': 0.81.5 - '@react-native/codegen': 0.81.5(@babel/core@7.28.3) - '@react-native/community-cli-plugin': 0.81.5 - '@react-native/gradle-plugin': 0.81.5 - '@react-native/js-polyfills': 0.81.5 - '@react-native/normalize-colors': 0.81.5 - '@react-native/virtualized-lists': 0.81.5(react-native@0.81.5)(react@19.1.0) - abort-controller: 3.0.0 - anser: 1.4.10 - ansi-regex: 5.0.1 - babel-jest: 29.7.0(@babel/core@7.28.3) - babel-plugin-syntax-hermes-parser: 0.29.1 - base64-js: 1.5.1 - commander: 12.1.0 - flow-enums-runtime: 0.0.6 - glob: 7.2.3 - invariant: 2.2.4 - jest-environment-node: 29.7.0 - memoize-one: 5.2.1 - metro-runtime: 0.83.2 - metro-source-map: 0.83.2 - nullthrows: 1.1.1 - pretty-format: 29.7.0 - promise: 8.3.0 - react: 19.1.0 - react-devtools-core: 6.1.5 - react-refresh: 0.14.2 - regenerator-runtime: 0.13.11 - scheduler: 0.26.0 - semver: 7.7.3 - stacktrace-parser: 0.1.10 - whatwg-fetch: 3.6.20 - ws: 6.2.3 - yargs: 17.7.2 - transitivePeerDependencies: - - '@babel/core' - - '@react-native-community/cli' - - '@react-native/metro-config' - - bufferutil - - supports-color - - utf-8-validate - dev: false - /react-promise-suspense@0.3.4: resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==} dependencies: