diff --git a/imports/api/appointments.ts b/imports/api/appointments.ts index cedbec38f..ca836a1a6 100644 --- a/imports/api/appointments.ts +++ b/imports/api/appointments.ts @@ -7,9 +7,12 @@ import { type AppointmentFields, } from './collection/appointments'; -const sanitizeAppointmentGen = function* ( - fields: Partial, -): IterableIterator> { +const sanitizeAppointmentGen = function* (fields: { + patientId: string; + datetime?: Date; + duration?: number; + reason: string; +}): IterableIterator> { yield* yieldKey(fields, 'patientId', String); if ( Object.prototype.hasOwnProperty.call(fields, 'datetime') || @@ -32,12 +35,12 @@ const sanitizeAppointmentGen = function* ( const sanitizeAppointment = makeSanitize(sanitizeAppointmentGen); export type AppointmentUpdate = { - patient: { + patient?: { _id: string; firstname?: string; lastname?: string; }; - phone: string; + phone?: string; datetime: Date; duration: number; reason: string; diff --git a/imports/api/consultations.ts b/imports/api/consultations.ts index 48dbbae65..0b42926c0 100644 --- a/imports/api/consultations.ts +++ b/imports/api/consultations.ts @@ -33,8 +33,13 @@ export const DEFAULT_DURATION_IN_SECONDS = DEFAULT_DURATION_IN_MINUTES * 60; export const DEFAULT_DURATION_IN_MILLISECONDS = DEFAULT_DURATION_IN_SECONDS * 1000; -export const isUnpaid = ({price = undefined, paid = undefined}) => - paid !== price; +export const isUnpaid = ({ + price = undefined, + paid = undefined, +}: { + price?: number; + paid?: number; +}) => paid !== price; const findLastConsultationArgs = ( filter?: Filter, @@ -161,7 +166,7 @@ export function setupConsultationsStatsPublication(collection, query) { const [oldPrice, minRef, maxRef] = refs.get(_id); let newPrice: number = oldPrice; if (Object.prototype.hasOwnProperty.call(fields, 'price')) { - newPrice = fields.price; + newPrice = fields.price!; if (oldPrice) total -= oldPrice; if (newPrice) total += newPrice; refs.set(_id, [newPrice, minRef, maxRef]); @@ -195,10 +200,10 @@ export function setupConsultationsStatsPublication(collection, query) { return handle; } -const trimString = (value: string | undefined) => value?.trim(); +const trimString = (value: any) => value?.trim(); const sanitizeUpdate = function* ( - fields: Partial, + fields: ConsultationFields, ): IterableIterator> { yield* yieldKey(fields, 'patientId', String); diff --git a/imports/api/duration.ts b/imports/api/duration.ts index a29c5c42b..621eb692b 100644 --- a/imports/api/duration.ts +++ b/imports/api/duration.ts @@ -5,25 +5,26 @@ const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; const WEEK = 7 * DAY; -type UnitsRecord = Record; -type UnitsArray = Array<[string, number]>; - -export const units: UnitsRecord = { +export const units = { week: WEEK, day: DAY, hour: HOUR, minute: MINUTE, second: SECOND, millisecond: MILLISECOND, -}; +} as const; -const DEFAULT_UNITS: UnitsArray = [ +export type UnitsRecord = typeof units; + +const DEFAULT_UNITS = [ ['week', WEEK], ['day', DAY], ['hour', HOUR], ['minute', MINUTE], ['second', SECOND], -]; +] as const; + +export type UnitsArray = typeof DEFAULT_UNITS; const DEFAULT_REST_UNIT = 'millisecond'; diff --git a/imports/api/endpoint/EndpointError.ts b/imports/api/endpoint/EndpointError.ts new file mode 100644 index 000000000..e3d34ebdb --- /dev/null +++ b/imports/api/endpoint/EndpointError.ts @@ -0,0 +1,5 @@ +import {Meteor} from 'meteor/meteor'; + +const EndpointError = Meteor.Error; + +export default EndpointError; diff --git a/imports/api/endpoint/Options.ts b/imports/api/endpoint/Options.ts index c4d1107ac..58b4bcd58 100644 --- a/imports/api/endpoint/Options.ts +++ b/imports/api/endpoint/Options.ts @@ -1,13 +1,7 @@ import {type Meteor} from 'meteor/meteor'; -import {type EJSONable, type EJSONableProperty} from 'meteor/ejson'; +import type Serializable from '../Serializable'; -type Options< - Result extends - | EJSONable - | EJSONable[] - | EJSONableProperty - | EJSONableProperty[], -> = { +type Options = { wait?: boolean | undefined; onResultReceived?: | ((error: Error | Meteor.Error | undefined, result?: Result) => void) diff --git a/imports/api/endpoint/appointments/schedule.ts b/imports/api/endpoint/appointments/schedule.ts index 1971e6efb..dbac55cf8 100644 --- a/imports/api/endpoint/appointments/schedule.ts +++ b/imports/api/endpoint/appointments/schedule.ts @@ -27,9 +27,10 @@ export default define({ } = sanitizeAppointmentUpdate(appointment); assert($unset === undefined || Object.keys($unset).length === 0); - validate($set.begin, Date); - validate($set.end, Date); - validate($set.reason, String); + assert($set !== undefined); + assert($set.begin instanceof Date); + assert($set.end instanceof Date); + assert(typeof $set.reason === 'string'); const owner = this.userId; @@ -38,7 +39,7 @@ export default define({ createPatient, ]); } else { - validate($set.patientId, String); + assert(typeof $set.patientId === 'string'); const patient = await db.findOne(Patients, { _id: $set.patientId, owner, diff --git a/imports/api/endpoint/availability/next.ts b/imports/api/endpoint/availability/next.ts index 72d02c2e6..b14e14766 100644 --- a/imports/api/endpoint/availability/next.ts +++ b/imports/api/endpoint/availability/next.ts @@ -6,7 +6,7 @@ import define from '../define'; import properlyIntersectsWithRightOpenInterval from '../../interval/containsDate'; import isContainedInRightOpenIterval from '../../interval/beginsAfterDate'; import overlapsInterval from '../../interval/overlapsInterval'; -import {Availability} from '../../collection/availability'; +import {Availability, type SlotDocument} from '../../collection/availability'; import { type Constraint, type Duration, @@ -36,7 +36,7 @@ export default define({ const owner = this.userId; - const properlyIntersecting = await db.fetch( + const properlyIntersecting: SlotDocument[] = await db.fetch( Availability, { $and: [{owner}, properlyIntersectsWithRightOpenInterval(after)], @@ -53,11 +53,13 @@ export default define({ return initialSlot(owner); } + const slot = properlyIntersecting[0]!; + if ( - properlyIntersecting[0].weight === 0 && - overlapsAfterDate(after, duration, constraints, properlyIntersecting[0]) + slot.weight === 0 && + overlapsAfterDate(after, duration, constraints, slot) ) { - return properlyIntersecting[0]; + return slot; } const firstContainedAndOverlapping = await db.findOne( diff --git a/imports/api/endpoint/compose.ts b/imports/api/endpoint/compose.ts index 43ea95052..41a80a66c 100644 --- a/imports/api/endpoint/compose.ts +++ b/imports/api/endpoint/compose.ts @@ -4,9 +4,9 @@ import type Args from '../Args'; import type Context from './Context'; import type Endpoint from './Endpoint'; -const compose = async ( +const compose = async ( db: TransactionDriver, - endpoint: Endpoint, + endpoint: Endpoint, invocation: Partial, args: A, ) => { diff --git a/imports/api/endpoint/documents/delete.ts b/imports/api/endpoint/documents/delete.ts index ab98513b0..6c74dfaf2 100644 --- a/imports/api/endpoint/documents/delete.ts +++ b/imports/api/endpoint/documents/delete.ts @@ -24,5 +24,6 @@ export default define({ await db.updateOne(Documents, {_id: documentId}, {$set: {deleted: true}}); await updateLastVersionFlags(db, this.userId, document); + return undefined; }, }); diff --git a/imports/api/endpoint/documents/restore.ts b/imports/api/endpoint/documents/restore.ts index fed432f89..447bd6cbd 100644 --- a/imports/api/endpoint/documents/restore.ts +++ b/imports/api/endpoint/documents/restore.ts @@ -24,5 +24,6 @@ export default define({ await db.updateOne(Documents, {_id: documentId}, {$set: {deleted: false}}); await updateLastVersionFlags(db, this.userId, document); + return undefined; }, }); diff --git a/imports/api/endpoint/documents/superdelete.ts b/imports/api/endpoint/documents/superdelete.ts index 6cbdbccd5..468967a9e 100644 --- a/imports/api/endpoint/documents/superdelete.ts +++ b/imports/api/endpoint/documents/superdelete.ts @@ -24,5 +24,6 @@ export default define({ await db.deleteOne(Documents, {_id: documentId}); await updateLastVersionFlags(db, this.userId, document); + return undefined; }, }); diff --git a/imports/api/endpoint/invoke.ts b/imports/api/endpoint/invoke.ts index e7cac7775..f796904db 100644 --- a/imports/api/endpoint/invoke.ts +++ b/imports/api/endpoint/invoke.ts @@ -1,17 +1,16 @@ -import {Meteor} from 'meteor/meteor'; import authorized from '../authorized'; +import type Serializable from '../Serializable'; import type Args from '../Args'; import type Context from './Context'; import type Endpoint from './Endpoint'; +import EndpointError from './EndpointError'; -const EndpointError = Meteor.Error; - -const invoke = async ( +const invoke = async ( endpoint: Endpoint, invocation: Partial, args: A, -): Promise => { +): Promise => { if (!authorized(endpoint.authentication, invocation)) { throw new EndpointError('not-authorized'); } diff --git a/imports/api/endpoint/patients/merge.ts b/imports/api/endpoint/patients/merge.ts index 1d050848e..a18c8dd72 100644 --- a/imports/api/endpoint/patients/merge.ts +++ b/imports/api/endpoint/patients/merge.ts @@ -20,7 +20,7 @@ export default define({ consultationIds: string[], attachmentIds: string[], documentIds: string[], - newPatient: Partial, + newPatient: PatientFields, ) { check(oldPatientIds, Array); check(consultationIds, Array); @@ -34,7 +34,7 @@ export default define({ consultationIds: string[], attachmentIds: string[], documentIds: string[], - newPatient: Partial, + newPatient: PatientFields, ) { // Here is what is done in this method // (2) Check that each patient in `oldPatientIds` is owned by the user @@ -165,7 +165,7 @@ export default define({ _consultationIds: string[], _attachmentIds: string[], _documentIds: string[], - _newPatient: Partial, + _newPatient: PatientFields, ) { return undefined; }, diff --git a/imports/api/makeObservedQueryHook.ts b/imports/api/makeObservedQueryHook.ts index ed387b166..e95fedbfd 100644 --- a/imports/api/makeObservedQueryHook.ts +++ b/imports/api/makeObservedQueryHook.ts @@ -1,4 +1,3 @@ -import {type Meteor} from 'meteor/meteor'; import {type DependencyList, useEffect, useRef} from 'react'; import useForceUpdate from '../ui/hooks/useForceUpdate'; @@ -12,6 +11,7 @@ import type GenericQueryHook from './GenericQueryHook'; import findOneSync from './publication/findOneSync'; import type Selector from './Selector'; import type Options from './Options'; +import type SubscriptionHandle from './publication/SubscriptionHandle'; const makeObservedQueryHook = ( @@ -22,7 +22,7 @@ const makeObservedQueryHook = const loading = useRef(true); const results = useRef([]); const dirty = useRef(false); - const handleRef = useRef(null); + const handleRef = useRef(null); const forceUpdate = useForceUpdate(); const effectWillTrigger = useChanged(deps); diff --git a/imports/api/patients.ts b/imports/api/patients.ts index 72addedf1..756f7bdf2 100644 --- a/imports/api/patients.ts +++ b/imports/api/patients.ts @@ -50,7 +50,7 @@ const splitNames = (string: string) => { const [firstname, ...middlenames] = names(string); const firstnameWords = words(firstname ?? ''); const middlenameWords = words(middlenames.join('')); - return [firstnameWords, middlenameWords]; + return [firstnameWords, middlenameWords] as const; }; function normalizedName(firstname, lastname) { @@ -71,7 +71,7 @@ const updateIndex = async ( deathdateModifiedAt, sex, }: PatientDocument, - $unset = undefined, + $unset?: {}, ) => { const [firstnameWords, middlenameWords] = splitNames(firstname); const lastnameWords = keepUnique(words(lastname)); @@ -142,7 +142,7 @@ const sanitizePatientTag = ({ ...rest, }); -const trimString = (value: string | undefined) => value?.trim(); +const trimString = (value: any) => value?.trim(); const sanitizePatientTags = (tags) => list(map(sanitizePatientTag, tags)); const where = Match.Where; @@ -307,7 +307,7 @@ function mergePatients(oldPatients: PatientFields[]): PatientFields { for (const tag of tags) { if (result.has(tag.name)) { if (tag.comment) { - const newTag = result.get(tag.name); + const newTag = result.get(tag.name)!; if (newTag.comment === undefined) { newTag.comment = tag.comment; } else { diff --git a/imports/api/publication/SubscriptionHandle.ts b/imports/api/publication/SubscriptionHandle.ts new file mode 100644 index 000000000..489b3d3ff --- /dev/null +++ b/imports/api/publication/SubscriptionHandle.ts @@ -0,0 +1,5 @@ +import {type Meteor} from 'meteor/meteor'; + +type SubscriptionHandle = Meteor.SubscriptionHandle; + +export default SubscriptionHandle; diff --git a/imports/api/publication/stats/frequencyBySex.ts b/imports/api/publication/stats/frequencyBySex.ts index 8753eaa67..b4a7dc8ca 100644 --- a/imports/api/publication/stats/frequencyBySex.ts +++ b/imports/api/publication/stats/frequencyBySex.ts @@ -48,25 +48,27 @@ export default define({ const inc = (patientId: string) => { if (!pRefs.has(patientId)) throw new Error(`inc: patientId ${patientId} does not exist`); - const patient = pRefs.get(patientId); - count[patient.freq][patient.sex] -= 1; + const patient = pRefs.get(patientId)!; + count[patient.freq]![patient.sex] -= 1; patient.freq += 1; if (count[patient.freq] === undefined) count[patient.freq] = {}; - if (count[patient.freq][patient.sex] === undefined) - count[patient.freq][patient.sex] = 0; - (count[patient.freq][patient.sex] as number) += 1; + if (count[patient.freq]![patient.sex] === undefined) + count[patient.freq]![patient.sex] = 0; + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + count[patient.freq]![patient.sex] += 1; }; const dec = (patientId: string) => { if (!pRefs.has(patientId)) throw new Error(`dec: patientId ${patientId} does not exist`); - const patient = pRefs.get(patientId); - count[patient.freq][patient.sex] -= 1; + const patient = pRefs.get(patientId)!; + count[patient.freq]![patient.sex] -= 1; patient.freq -= 1; if (count[patient.freq] === undefined) count[patient.freq] = {}; - if (count[patient.freq][patient.sex] === undefined) - count[patient.freq][patient.sex] = 0; - (count[patient.freq][patient.sex] as number) += 1; + if (count[patient.freq]![patient.sex] === undefined) + count[patient.freq]![patient.sex] = 0; + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + count[patient.freq]![patient.sex] += 1; }; let initializing = true; @@ -83,14 +85,16 @@ export default define({ added(_id, {sex}) { pRefs.set(_id, {freq: 0, sex}); if (count[0][sex] === undefined) count[0][sex] = 0; - (count[0][sex] as number) += 1; + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + count[0][sex] += 1; commit(); }, changed(_id, {sex}) { const {freq, sex: prev} = pRefs.get(_id); count[freq][prev] -= 1; if (count[freq][sex] === undefined) count[freq][sex] = 0; - (count[freq][sex] as number) += 1; + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + count[freq][sex] += 1; pRefs.set(_id, {freq, sex}); commit(); }, diff --git a/imports/api/publication/useItem.ts b/imports/api/publication/useItem.ts index a268b6f88..fd1781c61 100644 --- a/imports/api/publication/useItem.ts +++ b/imports/api/publication/useItem.ts @@ -10,7 +10,7 @@ import findOneSync from './findOneSync'; const useItem = ( collection: Collection | null, selector: Selector, - options: Options, + options: Options | undefined, deps: DependencyList, ): U | undefined => useReactive( diff --git a/imports/api/publication/useSubscription.ts b/imports/api/publication/useSubscription.ts index 914a5d1d2..2ef807a82 100644 --- a/imports/api/publication/useSubscription.ts +++ b/imports/api/publication/useSubscription.ts @@ -9,7 +9,7 @@ import subscribe from './subscribe'; import type Publication from './Publication'; const useSubscriptionClient = ( - publication?: Publication, + publication?: Publication | null, ...args: A ): (() => boolean) => { const [loading, setLoading] = useState(true); @@ -39,7 +39,7 @@ const useSubscriptionClient = ( const useSubscriptionServer = ( // @ts-expect-error Those parameters are not used. - publication?: Publication, + publication?: Publication | null, // @ts-expect-error Those parameters are not used. ...args: A ): (() => boolean) => diff --git a/imports/api/string.ts b/imports/api/string.ts index 02e7bc64b..aac395b21 100644 --- a/imports/api/string.ts +++ b/imports/api/string.ts @@ -51,7 +51,7 @@ export const normalizedLine = (string: string): NormalizedLine => normalizeWhiteSpace(onlyLowerCaseASCII(string)).trim() as NormalizedLine; export const capitalized = (string: string) => - string[0].toUpperCase() + string.slice(1); + string.slice(0, 1).toUpperCase() + string.slice(1); export const onlyASCII = (string: string) => deburr(string); @@ -159,7 +159,7 @@ export const names = (string: string): string[] => split(onlyLowerCaseAlphabeticalAndHyphen(string, ' ')); const trigrams = (string: string): IterableIterator => - map(([a, b, c]: string[]) => a + b + c, window(3, string)); + map(([a, b, c]: [string, string, string]) => a + b + c, window(3, string)); const wrapTrigram = (x: string) => `0${x}0`; diff --git a/imports/api/transaction/TransactionDriver.ts b/imports/api/transaction/TransactionDriver.ts index feb1140fc..95a48098a 100644 --- a/imports/api/transaction/TransactionDriver.ts +++ b/imports/api/transaction/TransactionDriver.ts @@ -24,7 +24,7 @@ export type DeleteResult = MongoDeleteResult; export type Options = Record; type TransactionDriver = { - session: ClientSession; + session: ClientSession | null; // TODO template depends on Collection document type insertOne: ( Collection: Collection, diff --git a/imports/api/update.ts b/imports/api/update.ts index 43ded316f..07c516666 100644 --- a/imports/api/update.ts +++ b/imports/api/update.ts @@ -2,7 +2,7 @@ import {check} from 'meteor/check'; import {asyncIterableToArray} from '@async-iterable-iterator/async-iterable-to-array'; import type TransactionDriver from './transaction/TransactionDriver'; -const id = (x) => x; +const id = (x: T): T => x; export type Entry = { [K in keyof T]: [K, T[K]]; @@ -12,7 +12,7 @@ export const yieldKey = function* ( fields: T, key: K, type, - transform = id, + transform: (x: T[K]) => T[K] = id, ): IterableIterator<[K, T[K]]> { if (Object.prototype.hasOwnProperty.call(fields, key)) { check(fields[key], type); @@ -24,7 +24,7 @@ export const yieldResettableKey = function* ( fields: T, key: K, type, - transform = id, + transform: (x: T[K]) => T[K] = id, ): IterableIterator<[K, T[K]]> { if (Object.prototype.hasOwnProperty.call(fields, key)) { if (fields[key] !== undefined) check(fields[key], type); @@ -41,7 +41,7 @@ type Changes = { | undefined; }; -export const simulateUpdate = ( +export const simulateUpdate = ( state: undefined | T, {$set, $unset}: Changes, ): T => { @@ -80,7 +80,7 @@ export const makeComputedFields = }; export const makeComputeUpdate = - (computedFields: ComputedFields) => + (computedFields: ComputedFields) => async ( db: TransactionDriver, owner: string, @@ -103,7 +103,7 @@ export const makeComputeUpdate = }; }; -type SanitizeUpdate = (fields: Partial) => IterableIterator>; +type SanitizeUpdate = (fields: T) => IterableIterator>; const fromEntries = ( entries: Array<[K, V]>, @@ -114,7 +114,7 @@ const fromEntries = ( export const makeSanitize = (sanitizeUpdate: SanitizeUpdate) => - (fields: Partial): Changes => { + (fields: T): Changes => { const update = Array.from(sanitizeUpdate(fields)); return { $set: fromEntries( @@ -128,18 +128,18 @@ export const makeSanitize = }; }; -const documentDiffGen = function* ( +const documentDiffGen = function* ( prevState: T, newState: Required extends U ? U : never, ): IterableIterator> { for (const [key, newValue] of Object.entries(newState)) { if (JSON.stringify(newValue) !== JSON.stringify(prevState[key])) { - yield [key as keyof U, newValue]; + yield [key as keyof U, newValue as U[keyof U]]; } } }; -export const documentDiff = ( +export const documentDiff = ( prevState: T, newState: Required extends U ? U : never, ): Partial => diff --git a/imports/i18n/availableLocales.ts b/imports/i18n/availableLocales.ts index ad83b137c..021b4c162 100644 --- a/imports/i18n/availableLocales.ts +++ b/imports/i18n/availableLocales.ts @@ -1,7 +1,7 @@ -export const localeDescriptions: Readonly> = { +export const localeDescriptions = { 'en-US': 'English (US)', 'fr-BE': 'Français (Belgique)', 'nl-BE': 'Nederlands (Belgïe)', -}; +} as const; export default new Set(Object.keys(localeDescriptions)); diff --git a/imports/i18n/datetime.ts b/imports/i18n/datetime.ts index 74f810195..71e6e31d8 100644 --- a/imports/i18n/datetime.ts +++ b/imports/i18n/datetime.ts @@ -17,6 +17,7 @@ import { ALL_WEEK_DAYS, someDateAtGivenDayOfWeek, someDateAtGivenPositionOfYear, + type WeekDay, } from '../lib/datetime'; import useLocaleKey from './useLocale'; @@ -41,7 +42,7 @@ export const dateTimeMaskMap = { 'fr-BE': `${dateMaskMap['fr-BE']} __:__`, }; -const localesCache = new Map(); +const localesCache = new Map(); const getLocale = async (owner: string): Promise => { const key = getSetting(owner, 'lang'); @@ -65,7 +66,9 @@ const getLocale = async (owner: string): Promise => { export const useLocale = () => { const key = useLocaleKey(); - const [lastLoadedLocale, setLastLoadedLocale] = useState(undefined); + const [lastLoadedLocale, setLastLoadedLocale] = useState( + undefined, + ); useEffect(() => { if (localesCache.has(key)) { @@ -96,10 +99,10 @@ export const useLocale = () => { return localesCache.has(key) ? localesCache.get(key) : lastLoadedLocale; }; -export type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6; +export type WeekStartsOn = WeekDay; export type FirstWeekContainsDate = 1 | 2 | 3 | 4 | 5 | 6 | 7; -const localeToWeekStartsOn = (locale: Locale): WeekStartsOn => +const localeToWeekStartsOn = (locale: Locale | undefined): WeekStartsOn => locale?.options?.weekStartsOn ?? 0; export const useLocaleWeekStartsOn = (): WeekStartsOn => { @@ -113,8 +116,9 @@ export const useWeekStartsOn = (): WeekStartsOn => { return setting === 'locale' ? localized : setting; }; -const localeToFirstWeekContainsDate = (locale: Locale): FirstWeekContainsDate => - locale?.options?.firstWeekContainsDate ?? 1; +const localeToFirstWeekContainsDate = ( + locale: Locale | undefined, +): FirstWeekContainsDate => locale?.options?.firstWeekContainsDate ?? 1; export const useLocaleFirstWeekContainsDate = (): FirstWeekContainsDate => { const locale = useLocale(); @@ -254,10 +258,10 @@ export const useDaysPositions = (positions: number[]) => { ); }; -export const useDaysNames = (days: number[]) => { +export const useDaysNames = (days: readonly D[]) => { const format = useDateFormat('cccc'); return useMemo( - () => days.map((i) => format(someDateAtGivenDayOfWeek(i))), + () => new Map(days.map((i) => [i, format(someDateAtGivenDayOfWeek(i))])), [days, format], ); }; diff --git a/imports/lib/arithmetic.ts b/imports/lib/arithmetic.ts index de9d85aca..3d0199a04 100644 --- a/imports/lib/arithmetic.ts +++ b/imports/lib/arithmetic.ts @@ -1 +1,4 @@ -export const mod = (n: number, m: number) => ((n % m) + m) % m; +import type IntegersModN from './types/IntegersModN'; + +export const mod = (n: number, m: M) => + (((n % m) + m) % m) as IntegersModN; diff --git a/imports/lib/cache/lru.ts b/imports/lib/cache/lru.ts index 4bfb81f7c..56f00a5b9 100644 --- a/imports/lib/cache/lru.ts +++ b/imports/lib/cache/lru.ts @@ -1,5 +1,13 @@ import assert from 'assert'; -import {openDB, type DBSchema, type IDBPDatabase} from 'idb/with-async-ittr'; +import type { + IDBPCursorWithValue, + IDBPCursorWithValueIteratorValue, + IDBPObjectStore, +} from 'idb'; + +import type {DBSchema, IDBPDatabase} from 'idb/with-async-ittr'; + +import {openDB} from 'idb/with-async-ittr'; const DEFAULT_DB_NAME = 'cache-lru'; const DB_VERSION = 1; @@ -11,64 +19,163 @@ const EXPIRY = 'expiry'; const RO = 'readonly'; const RW = 'readwrite'; const ASCENDING = 'next'; +const DESCENDING = 'prev'; const upTo = (ub: T) => IDBKeyRange.upperBound(ub, true); -type Key = string; -type Value = string; -type Expiry = Date; -type Access = Date; +export type Expiry = Date; +export type Access = Date; + +export type IDBValidKey = number | string | Date | BufferSource | IDBValidKey[]; + +export type Fields = { + [VALUE]: V; + [EXPIRY]: Expiry; +}; + +export type Value = Fields & { + [KEY]: K; + [ACCESS]: Access; +}; -type Schema = { +export type Schema = DBSchema & { [STORE]: { - key: Key; - value: { - [KEY]: Key; - [VALUE]: Value; - [EXPIRY]: Expiry; - [ACCESS]: Access; - }; + key: K; + value: Value; indexes: { [EXPIRY]: Expiry; [ACCESS]: Access; }; }; -} & DBSchema; +}; -type IndexedField = keyof Schema[typeof STORE]['indexes']; +export type IndexedField = keyof Schema< + K, + V +>[typeof STORE]['indexes']; -type DB = IDBPDatabase; +export type DB = IDBPDatabase>; -type Metadata = { +export type Metadata = { [EXPIRY]: Expiry; [ACCESS]?: Access; }; -export class IndexedDBPersistedLRUCache { - readonly #dbPromise: Promise; - readonly #maxCount: number; +const deleteN = async ( + count: number, + iterable: AsyncIterable< + IDBPCursorWithValueIteratorValue< + Schema, + Array<'store'>, + 'store', + 'access' | 'expiry', + 'readwrite' + > + >, +): Promise => { + if (count > 0) { + for await (const cursor of iterable) { + await cursor.delete(); + if (--count === 0) return 0; + } + } - constructor(dbPromise: Promise, maxCount: number) { + return count; +}; + +const deleteAll = async ( + iterable: AsyncIterable< + IDBPCursorWithValueIteratorValue< + Schema, + Array<'store'>, + 'store', + 'access' | 'expiry', + 'readwrite' + > + >, +): Promise => { + let count = 0; + for await (const cursor of iterable) { + // See https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB#using_an_index + await cursor.delete(); + ++count; + } + + return count; +}; + +const expunge = async ( + store: Store, + count: number, + now: Date, +): Promise => { + if (count <= 0) return count; + // NOTE could also chain the async iterables + const leftToDelete = await deleteN( + count, + store.index(EXPIRY).iterate(upTo(now), ASCENDING), + ); + + assert(leftToDelete >= 0); + + if (leftToDelete === 0) return 0; + + return deleteN(leftToDelete, store.index(ACCESS).iterate(null, ASCENDING)); +}; + +type Store< + K extends IDBValidKey, + V, + M extends IDBTransactionMode, +> = IDBPObjectStore, ArrayLike, typeof STORE, M>; +type CursorWithValue< + K extends IDBValidKey, + V, + M extends IDBTransactionMode, +> = IDBPCursorWithValue< + Schema, + ArrayLike, + typeof STORE, + unknown, + M +>; + +export class IndexedDBPersistedLRUCache { + readonly #dbPromise: Promise>; + #maxCount: number; + + constructor(dbPromise: Promise>, maxCount: number) { assert(maxCount > 0); this.#dbPromise = dbPromise; this.#maxCount = maxCount; } + async resize(maxCount: number) { + assert(maxCount > 0); + const store = await this.store(RW); + const count = await store.count(); + await expunge(store, count - maxCount, new Date()); + this.#maxCount = maxCount; + } + async db() { return this.#dbPromise; } - async store(mode: IDBTransactionMode) { + async store(mode: M): Promise> { return this.db().then((db) => db.transaction([STORE], mode).objectStore(STORE), ); } - async index(mode: IDBTransactionMode, field: IndexedField) { + async index(mode: IDBTransactionMode, field: IndexedField) { return this.store(mode).then((store) => store.index(field)); } - async getCursor(mode: IDBTransactionMode, key: Key) { + async getCursor( + mode: M, + key: K, + ): Promise | null> { const store = await this.store(mode); return store.openCursor(key); } @@ -77,45 +184,67 @@ export class IndexedDBPersistedLRUCache { return this.store(RO).then(async (store) => store.count()); } - async set(key: Key, value: Value, options: Metadata) { - const store = await this.store(RW); + async __set( + store: Store, + key: K, + value: V, + options: Metadata, + ) { const cursor = await store.openCursor(key); const now = new Date(); const newDocument = { - [ACCESS]: now, + [ACCESS]: now, // TODO handle given access key, value, ...options, }; if (cursor === null) { const count = await store.count(); - assert(count <= this.#maxCount); // TODO handle resizing - if (count === this.#maxCount) { - // Count unchanged - let available; - const expired = await store - .index(EXPIRY) - .openCursor(upTo(now), ASCENDING); - if (expired === null) { - const lru = await store.index(ACCESS).openCursor(null, ASCENDING); - assert(lru !== null); - available = lru; - } else { - available = expired; - } - - await available.update(newDocument); - } else { - // Count incremented - await store.add(newDocument); - } + await expunge(store, count + 1 - this.#maxCount, now); + // Count incremented + await store.add(newDocument); } else { // Count unchanged await cursor.update(newDocument); } } - async find(key: Key) { + async set(key: K, value: V, options: Metadata) { + const store = await this.store(RW); + await this.__set(store, key, value, options); + } + + async upsert( + key: K, + init: () => Fields, + update: (value: Value) => Fields, + ) { + const store = await this.store(RW); + const cursor = await store.openCursor(key); + if (cursor === null) { + const {value, ...metadata} = init(); + return this.__set(store, key, value, metadata); + } + + const newDocument = { + ...cursor.value, + [ACCESS]: new Date(), + ...update(cursor.value), + }; + await cursor.update(newDocument); + } + + async update(key: K, update: (value: Value) => Fields) { + await this.upsert( + key, + () => { + throw new Error('not found'); + }, + update, + ); + } + + async find(key: K) { const now = new Date(); const cursor = await this.getCursor(RW, key); if (cursor === null) return undefined; @@ -132,18 +261,18 @@ export class IndexedDBPersistedLRUCache { return result; } - async get(key: Key) { - const result = this.find(key); + async get(key: K) { + const result = await this.find(key); if (result === undefined) throw new Error(`not found`); return result; } - async has(key: Key) { - const result = this.find(key); + async has(key: K) { + const result = await this.find(key); return result !== undefined; } - async delete(key: Key) { + async delete(key: K) { return this.db().then(async (db) => db.delete(STORE, key)); } @@ -154,37 +283,47 @@ export class IndexedDBPersistedLRUCache { async evict(count: number) { const now = new Date(); const index = await this.index(RW, ACCESS); - if (count > 0) { - for await (const cursor of index.iterate(upTo(now), ASCENDING)) { - await cursor.delete(); - if (--count === 0) break; - } - } + return deleteN(count, index.iterate(upTo(now), ASCENDING)); } async gc() { const now = new Date(); const index = await this.index(RW, EXPIRY); - for await (const cursor of index.iterate(upTo(now))) { - // see https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB#using_an_index - await cursor.delete(); + return deleteAll(index.iterate(upTo(now))); + } + + async *[Symbol.asyncIterator]() { + const index = await this.index(RO, ACCESS); + yield* index.iterate(null, DESCENDING); + } + + async toArray() { + const result: Array> = []; + for await (const cursor of this) { + result.push(cursor.value); } + + return result; } } -type CacheOptions = { +export type CacheOptions = { dbName?: string; dbVersion?: number; maxCount: number; }; -const cache = ({ +export const cache = ({ dbName = DEFAULT_DB_NAME, dbVersion = DB_VERSION, maxCount, }: CacheOptions) => { - const dbPromise = openDB(dbName, dbVersion, { - upgrade(db, oldVersion, newVersion) { + const dbPromise = openDB>(dbName, dbVersion, { + upgrade( + db: IDBPDatabase>, + oldVersion: number, + newVersion: number, + ) { console.debug(`upgrade ${db.name} from v${oldVersion} to v${newVersion}`); if (newVersion === 1) { const store = db.createObjectStore(STORE, {keyPath: KEY}); @@ -207,5 +346,3 @@ const cache = ({ return new IndexedDBPersistedLRUCache(dbPromise, maxCount); }; - -export default cache; diff --git a/imports/lib/datetime.ts b/imports/lib/datetime.ts index ebdeb99a5..f7b77c185 100644 --- a/imports/lib/datetime.ts +++ b/imports/lib/datetime.ts @@ -4,10 +4,9 @@ import addYears from 'date-fns/addYears'; import addDays from 'date-fns/addDays'; import getDay from 'date-fns/getDay'; -import {list} from '@iterable-iterator/list'; -import {range} from '@iterable-iterator/range'; +export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; -export const ALL_WEEK_DAYS = Object.freeze(list(range(7))); +export const ALL_WEEK_DAYS = Object.freeze([0, 1, 2, 3, 4, 5, 6] as const); // +/- 8_640_000_000_000_000 are the max Date bounds // but we want to be able to exactly compute duration between any two dates and diff --git a/imports/lib/object/removeUndefined.ts b/imports/lib/object/removeUndefined.ts index 2a958301f..e6a0f9aff 100644 --- a/imports/lib/object/removeUndefined.ts +++ b/imports/lib/object/removeUndefined.ts @@ -1,6 +1,6 @@ -const removeUndefined = (object: T) => +const removeUndefined = (object: T) => Object.fromEntries( Object.entries(object).filter(([_key, value]) => value !== undefined), - ) as Partial; + ) as {[K in keyof T]: T[K] extends {} ? T[K] : never}; export default removeUndefined; diff --git a/imports/lib/pdf/pdfthumbnails.ts b/imports/lib/pdf/pdfthumbnails.ts index e26595065..2ae62e702 100644 --- a/imports/lib/pdf/pdfthumbnails.ts +++ b/imports/lib/pdf/pdfthumbnails.ts @@ -11,10 +11,10 @@ import addDays from 'date-fns/addDays'; import {type DocumentInitParameters} from 'pdfjs-dist/types/src/display/api'; import {type PageViewport} from 'pdfjs-dist/types/src/display/display_utils'; -import lru, {type IndexedDBPersistedLRUCache} from '../cache/lru'; +import {cache as lru, type IndexedDBPersistedLRUCache} from '../cache/lru'; import {fetchPDF} from './pdf'; -let cache: IndexedDBPersistedLRUCache; +let cache: IndexedDBPersistedLRUCache; if (Meteor.isClient) { cache = lru({ dbName: 'pdf-thumbnails-cache', @@ -163,7 +163,7 @@ const toBlob = async ( ) => new Promise((resolve, reject) => { canvas.toBlob( - (blob: Blob) => { + (blob: Blob | null) => { if (blob === null) { reject(); } else { diff --git a/imports/lib/stream/promiseToStream.ts b/imports/lib/stream/promiseToStream.ts index 6b68d6088..3763f685e 100644 --- a/imports/lib/stream/promiseToStream.ts +++ b/imports/lib/stream/promiseToStream.ts @@ -4,7 +4,7 @@ const promiseToStream = (promise: Promise, options: ReadableOptions) => new StreamFromPromise(promise, options); class StreamFromPromise extends Readable { - #promise: Promise; + #promise: Promise | null; constructor(promise: Promise, options: ReadableOptions) { super(options); diff --git a/imports/lib/types/IntegersModN.ts b/imports/lib/types/IntegersModN.ts new file mode 100644 index 000000000..34c0fbbc5 --- /dev/null +++ b/imports/lib/types/IntegersModN.ts @@ -0,0 +1,10 @@ +// See https://stackoverflow.com/q/39494689 + +type IntegersModN< + N extends number, + Accumulator extends number[] = [], +> = Accumulator['length'] extends N + ? Accumulator[number] + : IntegersModN; + +export default IntegersModN; diff --git a/imports/lib/types/Timeout.ts b/imports/lib/types/Timeout.ts new file mode 100644 index 000000000..4b6b1ca3f --- /dev/null +++ b/imports/lib/types/Timeout.ts @@ -0,0 +1,3 @@ +type Timeout = ReturnType; + +export default Timeout; diff --git a/imports/ui/appointments/AppointmentDialog.tsx b/imports/ui/appointments/AppointmentDialog.tsx index 57c1e8584..2a171ae72 100644 --- a/imports/ui/appointments/AppointmentDialog.tsx +++ b/imports/ui/appointments/AppointmentDialog.tsx @@ -191,7 +191,7 @@ const AppointmentDialog = ({ ? 0 : intersectionWithWorkSchedule[ intersectionWithWorkSchedule.length - 1 - ][1] - intersectionWithWorkSchedule[0][0]; + ]![1] - intersectionWithWorkSchedule[0]![0]; const measure = Number(end) - Number(begin); assert(span <= measure); return ( @@ -231,7 +231,8 @@ const AppointmentDialog = ({ }; const patientIsSelected = patientList.length === 1; - const selectedPatientExists = patientIsSelected && patientList[0]._id !== '?'; + const selectedPatientExists = + patientIsSelected && patientList[0]!._id !== '?'; const phoneIsDisabled = !patientIsSelected; const phoneIsReadOnly = !phoneIsDisabled && selectedPatientExists; const phonePlaceholder = patientIsSelected diff --git a/imports/ui/appointments/EditAppointmentDialog.tsx b/imports/ui/appointments/EditAppointmentDialog.tsx index a1fa4ee90..3336ec0a0 100644 --- a/imports/ui/appointments/EditAppointmentDialog.tsx +++ b/imports/ui/appointments/EditAppointmentDialog.tsx @@ -1,3 +1,4 @@ +import assert from 'assert'; import React from 'react'; import withLazyOpening from '../modal/withLazyOpening'; @@ -18,6 +19,8 @@ const appointmentDiffGen = function* ( appointment: AppointmentDocument, update: AppointmentUpdate, ): IterableIterator> { + assert(update.patient !== undefined); + if ( JSON.stringify(update.datetime) !== JSON.stringify(appointment.datetime) || update.duration !== appointment.duration @@ -55,7 +58,8 @@ const appointmentDiff = ( appointment: AppointmentDocument, update: AppointmentUpdate, ): Partial => { - return Object.fromEntries(appointmentDiffGen(appointment, update)); + const entries = appointmentDiffGen(appointment, update); + return Object.fromEntries(entries); }; const EditAppointmentDialog = ({open, onClose, appointment}: Props) => { diff --git a/imports/ui/books/BooksDownloadDialog.tsx b/imports/ui/books/BooksDownloadDialog.tsx index eb8f420c5..a8e89fe8d 100644 --- a/imports/ui/books/BooksDownloadDialog.tsx +++ b/imports/ui/books/BooksDownloadDialog.tsx @@ -1,3 +1,4 @@ +import assert from 'assert'; import React, {useState, useEffect} from 'react'; import addYears from 'date-fns/addYears'; @@ -52,17 +53,24 @@ const BooksDownloadDialog = ({ const [advancedFunctionality, setAdvancedFunctionality] = useState( initialAdvancedFunctionality, ); - const [begin, setBegin] = useState(initialBegin); - const [end, setEnd] = useState(initialEnd ?? addYears(initialBegin, 1)); + const [begin, setBegin] = useState(initialBegin); + const [end, setEnd] = useState( + initialEnd ?? addYears(initialBegin, 1), + ); const [firstBook, setFirstBook] = useState(String(books.DOWNLOAD_FIRST_BOOK)); const [lastBook, setLastBook] = useState(String(books.DOWNLOAD_LAST_BOOK)); const [maxRows, setMaxRows] = useState(String(books.DOWNLOAD_MAX_ROWS)); - const setYear = (year) => { - const newBegin = new Date(year, 0, 1); - const newEnd = addYears(newBegin, 1); - setBegin(newBegin); - setEnd(newEnd); + const setYear = (year: number | undefined) => { + if (year === undefined) { + setBegin(null); + setEnd(null); + } else { + const newBegin = new Date(year, 0, 1); + const newEnd = addYears(newBegin, 1); + setBegin(newBegin); + setEnd(newEnd); + } }; useEffect(() => { @@ -75,6 +83,8 @@ const BooksDownloadDialog = ({ }, [initialAdvancedFunctionality, Number(initialBegin), Number(initialEnd)]); const downloadData = async (event) => { + assert(begin !== null); + assert(end !== null); event.preventDefault(); const _firstBook = Number.parseInt(firstBook, 10); const _lastBook = Number.parseInt(lastBook, 10); @@ -172,7 +182,7 @@ const BooksDownloadDialog = ({ label="Year" value={begin} onChange={(date) => { - setYear(date.getFullYear()); + setYear(date === null ? undefined : date.getFullYear()); }} /> @@ -192,6 +202,7 @@ const BooksDownloadDialog = ({ } loadingPosition="end" diff --git a/imports/ui/calendar/CalendarData.tsx b/imports/ui/calendar/CalendarData.tsx index 1806c6896..ff0d98d9d 100644 --- a/imports/ui/calendar/CalendarData.tsx +++ b/imports/ui/calendar/CalendarData.tsx @@ -16,7 +16,7 @@ import {enumerate} from '@iterable-iterator/zip'; import makeStyles from '../styles/makeStyles'; import {useDateFormat} from '../../i18n/datetime'; -import {ALL_WEEK_DAYS, generateDays} from '../../lib/datetime'; +import {ALL_WEEK_DAYS, generateDays, type WeekDay} from '../../lib/datetime'; import type CSSObject from '../styles/CSSObject'; import type Event from './Event'; import EventFragment from './EventFragment'; @@ -139,8 +139,8 @@ function* generateEventProps( ): IterableIterator { const {maxLines, skipIdle, minEventDuration, dayBegins} = options; - let previousEvent: {end: Date}; - let previousDay: string; + let previousEvent: {end: Date} | undefined; + let previousDay: string | undefined; for (const event of events) { if ( (event.end && isBefore(event.end, begin)) || @@ -180,7 +180,7 @@ function* generateEventProps( : 1 : 0; - let {usedSlots, totalEvents, shownEvents} = occupancy.get(day); + let {usedSlots, totalEvents, shownEvents} = occupancy.get(day)!; const slot = usedSlots + skip + 1; ++totalEvents; usedSlots += skip + slots; @@ -217,7 +217,7 @@ function* generateMoreProps( ): IterableIterator { for (const day of generateDays(begin, end)) { const key = dayKey(day); - const {totalEvents, shownEvents} = occupancy.get(key); + const {totalEvents, shownEvents} = occupancy.get(key)!; const count = totalEvents - shownEvents; if (count > 0) { yield { @@ -239,7 +239,7 @@ type CalendarDataGridClasses = { }; type CalendarDataGridProps = { - DayHeader?: React.ElementType; + DayHeader: React.ElementType; WeekNumber?: React.ElementType; classes: CalendarDataGridClasses; cx: any; @@ -247,7 +247,7 @@ type CalendarDataGridProps = { days: DayProps[]; events: EventProps[]; mores: MoreProps[]; - weekOptions: {}; + weekOptions?: {}; onSlotClick?: () => void; onEventClick?: () => void; }; @@ -280,7 +280,7 @@ const CalendarDataGrid = ({ @@ -311,8 +311,8 @@ const CalendarDataGrid = ({ @@ -346,7 +346,7 @@ type CalendarDataProps = { DayHeader: React.ElementType; WeekNumber?: React.ElementType; lineHeight?: string; - displayedWeekDays?: number[]; + displayedWeekDays?: readonly WeekDay[]; onSlotClick?: () => void; onEventClick?: () => void; }; diff --git a/imports/ui/calendar/ReactiveWeeklyCalendar.tsx b/imports/ui/calendar/ReactiveWeeklyCalendar.tsx index f5d87367d..46aef36d9 100644 --- a/imports/ui/calendar/ReactiveWeeklyCalendar.tsx +++ b/imports/ui/calendar/ReactiveWeeklyCalendar.tsx @@ -70,6 +70,7 @@ import {useSetting} from '../settings/hooks'; import call from '../../api/endpoint/call'; import type ModuloWeekInterval from '../settings/ModuloWeekInterval'; import ColorChip from '../chips/ColorChip'; +import {type Constraint} from '../../api/availability'; import Header from './Header'; import DayHeader from './DayHeader'; import StaticWeeklyCalendar from './StaticWeeklyCalendar'; @@ -283,7 +284,7 @@ const ReactiveWeeklyCalendar = ({ // are given to next. const weekConstraint: Array<[number, number]> = [[0, units.week + duration]]; - const constraints = Array.from( + const constraints: Constraint[] = Array.from( nonOverlappingIntersection( weekConstraint, map( diff --git a/imports/ui/consultations/ConsultationDebtSettlementDialog.tsx b/imports/ui/consultations/ConsultationDebtSettlementDialog.tsx index db337f44b..e82a0907c 100644 --- a/imports/ui/consultations/ConsultationDebtSettlementDialog.tsx +++ b/imports/ui/consultations/ConsultationDebtSettlementDialog.tsx @@ -1,3 +1,4 @@ +import assert from 'assert'; import React, {useCallback} from 'react'; import {useSnackbar} from 'notistack'; @@ -28,6 +29,10 @@ const ConsultationDebtSettlementDialog = ({ }: Props) => { const {_id, patientId, currency, price, paid} = consultation; + assert(currency !== undefined); + assert(price !== undefined); + assert(paid !== undefined); + const owed = price - paid; const options = {fields: ConsultationDebtSettlementDialog.projection}; diff --git a/imports/ui/consultations/ConsultationEditor.tsx b/imports/ui/consultations/ConsultationEditor.tsx index 27134536e..ac9c1594c 100644 --- a/imports/ui/consultations/ConsultationEditor.tsx +++ b/imports/ui/consultations/ConsultationEditor.tsx @@ -76,6 +76,7 @@ const init = (consultation: ConsultationEditorFields): State => { const inBookNumberString = Number.isInteger(consultation.inBookNumber) && + consultation.inBookNumber !== undefined && consultation.inBookNumber >= 1 ? String(consultation.inBookNumber) : ''; @@ -106,18 +107,27 @@ const init = (consultation: ConsultationEditorFields): State => { priceError: !isValidAmount(priceString), paidError: !isValidAmount(paidString), inBookNumberError: !isValidInBookNumber(inBookNumberString), - inBookNumberDisabled: !isRealBookNumber(consultation.book), + inBookNumberDisabled: + typeof consultation.book !== 'string' || + !isRealBookNumber(consultation.book), }), }; }; -type Action = { - type: string; - key?: string; - value?: any; - insertedId?: string; - inBookNumber?: number; -}; +type Action = + | ({type: 'update'} & ( + | {key: 'paidString'; value: string} + | {key: 'priceString'; value: string} + | {key: 'inBookNumberString'; value: string} + | {key: string; value: string} + )) + | {type: 'save-success'; insertedId?: string} + | {type: 'sync-in-book-number'; inBookNumber: number} + | {type: 'loading-next-in-book-number'} + | {type: 'disable-in-book-number'} + | {type: 'save'} + | {type: 'save-failure'} + | {type: 'not-dirty'}; const reducer = (state: State, action: Action) => { switch (action.type) { @@ -244,6 +254,7 @@ const reducer = (state: State, action: Action) => { } default: { + // @ts-expect-error NOTE Can code defensively and be type-safe. throw new Error(`Unknown action type ${action.type}.`); } } diff --git a/imports/ui/consultations/ConsultationForm.tsx b/imports/ui/consultations/ConsultationForm.tsx index 86865984a..c9c74a7ca 100644 --- a/imports/ui/consultations/ConsultationForm.tsx +++ b/imports/ui/consultations/ConsultationForm.tsx @@ -85,7 +85,14 @@ export const defaultState = { lastInsertedId: undefined, }; -export type State = typeof defaultState; +export type State = Omit< + typeof defaultState, + '_id' | 'doneDatetime' | 'lastInsertedId' +> & { + _id?: string; + doneDatetime?: Date; + lastInsertedId?: string; +}; type Props = { consultation: State; @@ -135,7 +142,8 @@ const ConsultationForm = ({consultation, update}: Props) => { const bookIsFull = !loadingBookStats && - bookStats?.count >= + bookStats !== undefined && + bookStats.count >= books.MAX_CONSULTATIONS + (_id && initialBookName === bookName ? 1 : 0) && books.isReal(bookName); @@ -145,12 +153,14 @@ const ConsultationForm = ({consultation, update}: Props) => { loading: loadingInBookNumberCollisions, result: inBookNumberCollisions, } = useBookStats(bookName, { - _id: {$nin: [_id]}, + _id: _id && {$nin: [_id]}, inBookNumber, }); const inBookNumberCollides = - !loadingInBookNumberCollisions && inBookNumberCollisions?.count >= 1; + !loadingInBookNumberCollisions && + inBookNumberCollisions !== undefined && + inBookNumberCollisions.count >= 1; const consultationYear = getYear(datetime); diff --git a/imports/ui/consultations/ConsultationPaymentDialog.tsx b/imports/ui/consultations/ConsultationPaymentDialog.tsx index 76335d7b8..171a2d51d 100644 --- a/imports/ui/consultations/ConsultationPaymentDialog.tsx +++ b/imports/ui/consultations/ConsultationPaymentDialog.tsx @@ -1,3 +1,4 @@ +import assert from 'assert'; import React from 'react'; import dateFormat from 'date-fns/format'; @@ -75,6 +76,9 @@ const ConsultationPaymentDialog = ({open, onClose, consultation}: Props) => { const {loading: loadingIban, value: iban} = useSetting('iban'); const {currency, price, paid, datetime} = consultation; + assert(currency !== undefined); + assert(price !== undefined); + assert(paid !== undefined); const currencyFormat = useCurrencyFormat(currency); @@ -94,8 +98,8 @@ const ConsultationPaymentDialog = ({open, onClose, consultation}: Props) => { const loading = loadingAccountHolder || loadingIban || loadingPatient; const _date = dateFormat(datetime, 'yyyy-MM-dd'); - const _lastname = onlyASCII(patient.lastname); - const _firstname = onlyASCII(patient.firstname); + const _lastname = onlyASCII(patient.lastname ?? ''); + const _firstname = onlyASCII(patient.firstname ?? ''); const unstructuredReference = `${_date} ${_lastname} ${_firstname}`; const data = { diff --git a/imports/ui/consultations/ConsultationsOfTheDay.tsx b/imports/ui/consultations/ConsultationsOfTheDay.tsx index aa5ea5b79..cb99baee6 100644 --- a/imports/ui/consultations/ConsultationsOfTheDay.tsx +++ b/imports/ui/consultations/ConsultationsOfTheDay.tsx @@ -88,7 +88,7 @@ const ConsultationsOfTheDay = ({day}: Props) => { (showNoShowAppointments || c.isDone !== false || c.isCancelled || - !isBefore(c.scheduledDatetime, thisMorning)), + !isBefore(c.scheduledDatetime!, thisMorning)), ); const pm = consultations.filter( (c) => @@ -99,7 +99,7 @@ const ConsultationsOfTheDay = ({day}: Props) => { (showNoShowAppointments || c.isDone !== false || c.isCancelled || - !isBefore(c.scheduledDatetime, thisMorning)), + !isBefore(c.scheduledDatetime!, thisMorning)), ); const cam = count(am); const cpm = count(pm); diff --git a/imports/ui/doctors/StaticDoctorChip.tsx b/imports/ui/doctors/StaticDoctorChip.tsx index 516e8301e..fc9e45f6e 100644 --- a/imports/ui/doctors/StaticDoctorChip.tsx +++ b/imports/ui/doctors/StaticDoctorChip.tsx @@ -16,9 +16,9 @@ type StaticDoctorChipProps = ChipProps & AddedProps; const StaticDoctorChip = React.forwardRef( ({item, ...rest}, ref) => { - let style: React.CSSProperties; - let component: React.ElementType; - let to: string; + let style: React.CSSProperties | undefined; + let component: React.ElementType | undefined; + let to: string | undefined; if (item && !rest.onDelete) { component = Link; diff --git a/imports/ui/input/ColorPicker/transformers.ts b/imports/ui/input/ColorPicker/transformers.ts index 93a525896..13f168bbe 100644 --- a/imports/ui/input/ColorPicker/transformers.ts +++ b/imports/ui/input/ColorPicker/transformers.ts @@ -2,15 +2,18 @@ import {type ColorResult} from 'react-color'; export const DEFAULT_CONVERTER = 'rgba_hex'; -type Converter = (c: ColorResult) => string; +export type Converter = (c: ColorResult) => string; -export const converters: Record = { - rgba: (c) => `rgba(${c.rgb.r}, ${c.rgb.g}, ${c.rgb.b}, ${c.rgb.a})`, - rgb: (c) => `rgb(${c.rgb.r}, ${c.rgb.g}, ${c.rgb.b})`, - hex: (c) => c.hex, +export const converters = { + rgba: (c: ColorResult) => + `rgba(${c.rgb.r}, ${c.rgb.g}, ${c.rgb.b}, ${c.rgb.a})`, + rgb: (c: ColorResult) => `rgb(${c.rgb.r}, ${c.rgb.g}, ${c.rgb.b})`, + hex: (c: ColorResult) => c.hex, - rgba_rgb: (c) => (c.rgb.a === 1 ? converters.rgb(c) : converters.rgba(c)), - rgba_hex: (c) => (c.rgb.a === 1 ? converters.hex(c) : converters.rgba(c)), -}; + rgba_rgb: (c: ColorResult) => + c.rgb.a === 1 ? converters.rgb(c) : converters.rgba(c), + rgba_hex: (c: ColorResult) => + c.rgb.a === 1 ? converters.hex(c) : converters.rgba(c), +} as const; export default converters; diff --git a/imports/ui/input/ColorizedTextarea.tsx b/imports/ui/input/ColorizedTextarea.tsx index c5323544b..1e5cc1bec 100644 --- a/imports/ui/input/ColorizedTextarea.tsx +++ b/imports/ui/input/ColorizedTextarea.tsx @@ -12,7 +12,7 @@ type ReadOnlyColorizedTextAreaProps = { className?: string; label?: string; maxRows?: number; - dict: (piece: string) => boolean; + dict: null | ((piece: string) => boolean); value: string; }; diff --git a/imports/ui/input/SetPicker.tsx b/imports/ui/input/SetPicker.tsx index be9916196..ad50ef77a 100644 --- a/imports/ui/input/SetPicker.tsx +++ b/imports/ui/input/SetPicker.tsx @@ -194,7 +194,7 @@ const SetPicker = ({ const [inputValue, setInputValue] = useStateWithInitOverride(emptyInput); const [error, setError] = useState(false); - const [helperText, setHelperText] = useState(undefined); + const [helperText, setHelperText] = useState(undefined); const selectedItems = value; const count = selectedItems.length; @@ -352,7 +352,7 @@ const SetPicker = ({ }, startAdornment: ( ({ endAdornment: withoutToggle ? undefined : ( diff --git a/imports/ui/input/ValuePicker.tsx b/imports/ui/input/ValuePicker.tsx index 65465fedb..1f2bfbbd3 100644 --- a/imports/ui/input/ValuePicker.tsx +++ b/imports/ui/input/ValuePicker.tsx @@ -1,36 +1,38 @@ -import React from 'react'; +import React, {type ReactNode} from 'react'; import FormControl from '@mui/material/FormControl'; import InputLabel from '@mui/material/InputLabel'; -import Select from '@mui/material/Select'; +import Select, {type SelectChangeEvent} from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; import useUniqueId from '../hooks/useUniqueId'; -type Props = { - options: string[]; - optionToString?: (option: string) => string; - pairToKey?: (option: string, index: number) => React.Key; +type Props = { + options: T[]; + optionToString?: (option: T) => string; + pairToKey?: (option: T, index: number) => React.Key; // Select readOnly?: boolean; - label?: React.ReactNode; - value?: any; - onChange?: (e: any) => void; + label?: ReactNode; + value?: T; + onChange?: (e: SelectChangeEvent) => void; // input name?: string; }; -const ValuePicker = ({ +const ValuePicker = ({ label, name, value, onChange, options, + // eslint-disable-next-line @typescript-eslint/no-base-to-string optionToString = (x) => x.toString(), - pairToKey = (option, _index) => option, + // eslint-disable-next-line @typescript-eslint/no-base-to-string + pairToKey = (option, _index) => option.toString(), ...rest -}: Props) => { +}: Props) => { const id = useUniqueId('value-picker'); const selectId = `${id}-select`; const labelId = `${id}-label`; @@ -42,10 +44,12 @@ const ValuePicker = ({ labelId={labelId} id={selectId} value={value} + renderValue={optionToString} onChange={onChange} {...rest} > {options.map((option, index) => ( + // @ts-expect-error Types are wrong. {optionToString(option)} diff --git a/imports/ui/insurances/StaticInsuranceCard.tsx b/imports/ui/insurances/StaticInsuranceCard.tsx index bd6ae9fe4..dfa854964 100644 --- a/imports/ui/insurances/StaticInsuranceCard.tsx +++ b/imports/ui/insurances/StaticInsuranceCard.tsx @@ -45,7 +45,7 @@ const LoadedTagCard = ({loading, found, item}: LoadedTagCardProps) => { }); const subheader = count === undefined ? '...' : `assure ${count} patients`; const content = - patients === undefined ? ( + count === undefined || patients === undefined ? ( <>... ) : (
diff --git a/imports/ui/insurances/StaticInsuranceChip.tsx b/imports/ui/insurances/StaticInsuranceChip.tsx index 2b842e730..02b7f1a01 100644 --- a/imports/ui/insurances/StaticInsuranceChip.tsx +++ b/imports/ui/insurances/StaticInsuranceChip.tsx @@ -16,9 +16,9 @@ type StaticInsuranceChipProps = ChipProps & AddedProps; const StaticInsuranceChip = React.forwardRef( ({item, ...rest}, ref) => { - let style: React.CSSProperties; - let component: React.ElementType; - let to: string; + let style: React.CSSProperties | undefined; + let component: React.ElementType | undefined; + let to: string | undefined; if (item && !rest.onDelete) { component = Link; diff --git a/imports/ui/merge/MergePatientsConfirmationDialog.tsx b/imports/ui/merge/MergePatientsConfirmationDialog.tsx index de12d05cf..f76273ef1 100644 --- a/imports/ui/merge/MergePatientsConfirmationDialog.tsx +++ b/imports/ui/merge/MergePatientsConfirmationDialog.tsx @@ -13,11 +13,12 @@ import ConfirmationDialog from '../modal/ConfirmationDialog'; import withLazyOpening from '../modal/withLazyOpening'; import debounceSnackbar from '../snackbar/debounceSnackbar'; import useCall from '../action/useCall'; +import {type PatientFields} from '../../api/collection/patients'; type Props = { open: boolean; onClose: () => void; - toCreate: {}; + toCreate: PatientFields; consultationsToAttach: string[]; attachmentsToAttach: string[]; documentsToAttach: string[]; diff --git a/imports/ui/merge/MergePatientsFormStepPrepare.tsx b/imports/ui/merge/MergePatientsFormStepPrepare.tsx index 33cb849c5..14b532033 100644 --- a/imports/ui/merge/MergePatientsFormStepPrepare.tsx +++ b/imports/ui/merge/MergePatientsFormStepPrepare.tsx @@ -65,9 +65,9 @@ const StaticMergePatientsFormStepPrepare = ({ {patient._id} ))} diff --git a/imports/ui/modal/DialogOptions.ts b/imports/ui/modal/DialogOptions.ts new file mode 100644 index 000000000..8bc7844cb --- /dev/null +++ b/imports/ui/modal/DialogOptions.ts @@ -0,0 +1,23 @@ +export type Append = (child: JSX.Element) => void; +export type Replace = (target: JSX.Element, replacement: JSX.Element) => void; +export type Remove = (child: JSX.Element) => void; +export type Key = () => string; + +export const DEFAULT_APPEND: Append = (_child: JSX.Element) => { + throw new Error('append not implemented'); +}; + +export const DEFAULT_REPLACE: Replace = ( + _target: JSX.Element, + _replacement: JSX.Element, +) => { + throw new Error('replace not implemented'); +}; + +export const DEFAULT_REMOVE: Remove = (_child: JSX.Element) => { + throw new Error('remove not implemented'); +}; + +export const DEFAULT_KEY: Key = () => { + throw new Error('key not implemented'); +}; diff --git a/imports/ui/modal/ModalContext.ts b/imports/ui/modal/ModalContext.ts index bc957c8ea..b34662ca3 100644 --- a/imports/ui/modal/ModalContext.ts +++ b/imports/ui/modal/ModalContext.ts @@ -1,5 +1,27 @@ import {createContext} from 'react'; +import { + type Append, + type Key, + DEFAULT_APPEND, + DEFAULT_KEY, + DEFAULT_REMOVE, + DEFAULT_REPLACE, + type Remove, + type Replace, +} from './DialogOptions'; -const ModalContext = createContext([]); +export type Context = { + append: Append; + replace: Replace; + remove: Remove; + key: Key; +}; + +const ModalContext = createContext({ + append: DEFAULT_APPEND, + replace: DEFAULT_REPLACE, + remove: DEFAULT_REMOVE, + key: DEFAULT_KEY, +}); export default ModalContext; diff --git a/imports/ui/modal/ModelProvider.tsx b/imports/ui/modal/ModelProvider.tsx index d32780ce1..e352d5820 100644 --- a/imports/ui/modal/ModelProvider.tsx +++ b/imports/ui/modal/ModelProvider.tsx @@ -6,7 +6,7 @@ const key = () => `${++i}`; const ModalProvider = ({children}) => { // TODO use more efficient persistent data structure - const [modals, setModals] = useState([]); + const [modals, setModals] = useState([]); const context = useMemo(() => { const append = (child: JSX.Element) => { @@ -23,7 +23,7 @@ const ModalProvider = ({children}) => { ); }; - return [append, replace, remove, key]; + return {append, replace, remove, key}; }, [setModals]); return ( diff --git a/imports/ui/modal/dialog.ts b/imports/ui/modal/dialog.ts index ac36de0ba..a45e07907 100644 --- a/imports/ui/modal/dialog.ts +++ b/imports/ui/modal/dialog.ts @@ -1,22 +1,19 @@ import assert from 'assert'; import {cloneElement} from 'react'; +import {DEFAULT_APPEND, DEFAULT_REMOVE, DEFAULT_REPLACE} from './DialogOptions'; const DEFAULT_OPTIONS = { unmountDelay: 3000, openKey: 'open', - append(_child: JSX.Element) { - throw new Error('append not implemented'); - }, - replace(_target: JSX.Element, _replacement: JSX.Element) { - throw new Error('replace not implemented'); - }, - remove(_child: JSX.Element) { - throw new Error('remove not implemented'); - }, + append: DEFAULT_APPEND, + replace: DEFAULT_REPLACE, + remove: DEFAULT_REMOVE, key: undefined, }; -export type Options = typeof DEFAULT_OPTIONS; +export type Options = Omit & { + key: string | undefined; +}; type Resolve = (value: T | PromiseLike) => void; type Reject = () => void; @@ -25,14 +22,14 @@ export type ComponentExecutor = (resolve: Resolve, reject: Reject) => any; const dialog = async ( componentExecutor: ComponentExecutor, - options?: Options, + options?: Partial, ) => { const {unmountDelay, openKey, append, replace, remove, key} = { ...DEFAULT_OPTIONS, ...options, }; - let currentChild: JSX.Element = null; + let currentChild: JSX.Element | null = null; const render = (resolve, reject, open) => { const target = currentChild; diff --git a/imports/ui/modal/useDialog.ts b/imports/ui/modal/useDialog.ts index 9f971e74a..043251eac 100644 --- a/imports/ui/modal/useDialog.ts +++ b/imports/ui/modal/useDialog.ts @@ -3,7 +3,7 @@ import dialog, {type ComponentExecutor, type Options} from './dialog'; import ModalContext from './ModalContext'; const useDialog = () => { - const [append, replace, remove, key] = useContext(ModalContext); + const {append, replace, remove, key} = useContext(ModalContext); return useMemo(() => { return async ( componentExecutor: ComponentExecutor, diff --git a/imports/ui/navigation/TabJumper.tsx b/imports/ui/navigation/TabJumper.tsx index cd60b19e7..8e5e88fd9 100644 --- a/imports/ui/navigation/TabJumper.tsx +++ b/imports/ui/navigation/TabJumper.tsx @@ -4,7 +4,7 @@ import Jumper from './Jumper'; export type Props = { tabs: K[]; - current: K; + current?: K; toURL: (tab: K) => string; }; diff --git a/imports/ui/navigation/YearJumper.tsx b/imports/ui/navigation/YearJumper.tsx index e87f38c26..7828c1b3e 100644 --- a/imports/ui/navigation/YearJumper.tsx +++ b/imports/ui/navigation/YearJumper.tsx @@ -1,3 +1,4 @@ +import assert from 'assert'; import React from 'react'; import {range} from '@iterable-iterator/range'; @@ -11,6 +12,7 @@ import TabJumper, {type Props as TabJumperProps} from './TabJumper'; type Props = Omit, 'tabs'>; const YearJumper = ({current, ...rest}: Props) => { + assert(current !== undefined); const now = new Date(); const thisyear = now.getFullYear(); const end = Math.min(thisyear, current + 5) + 1; diff --git a/imports/ui/patients/PatientsObservedSearchResultsPage.tsx b/imports/ui/patients/PatientsObservedSearchResultsPage.tsx index 9f8f4e536..9d3666dc7 100644 --- a/imports/ui/patients/PatientsObservedSearchResultsPage.tsx +++ b/imports/ui/patients/PatientsObservedSearchResultsPage.tsx @@ -21,8 +21,8 @@ type Props = { query: string; page?: number; perpage?: number; - refresh: () => void; - refreshKey: number | string; + refresh?: () => void; + refreshKey?: number | string; } & Omit< PropsOf, 'page' | 'perpage' | 'loading' | 'patients' | 'root' | 'Card' diff --git a/imports/ui/patients/StaticPatientChip.tsx b/imports/ui/patients/StaticPatientChip.tsx index 4f38d8620..5970fc9af 100644 --- a/imports/ui/patients/StaticPatientChip.tsx +++ b/imports/ui/patients/StaticPatientChip.tsx @@ -77,8 +77,8 @@ const StaticPatientChip = React.forwardRef( }: Props, ref: any, ) => { - let component: React.ElementType; - let to: string; + let component: React.ElementType | undefined; + let to: string | undefined; if (!onClick && !onDelete) { component = Link; to = `/patient/${patient._id}`; @@ -95,7 +95,7 @@ const StaticPatientChip = React.forwardRef( avatar={ !loading && found && patient.photo ? ( - ) : null + ) : undefined } label={ loading diff --git a/imports/ui/patients/makePatientsSuggestions.ts b/imports/ui/patients/makePatientsSuggestions.ts index 20efd301c..b78738d74 100644 --- a/imports/ui/patients/makePatientsSuggestions.ts +++ b/imports/ui/patients/makePatientsSuggestions.ts @@ -8,12 +8,17 @@ import mergeFields from '../../api/query/mergeFields'; import {normalizeSearch} from '../../api/string'; import {TIMEOUT_INPUT_DEBOUNCE} from '../constants'; +import type Options from '../../api/Options'; +import {type PatientCacheItem} from '../../api/collection/patients/search/cache'; import useAdvancedObservedPatients from './useAdvancedObservedPatients'; const DEBOUNCE_OPTIONS = {leading: false}; // TODO this does not work because we do not render on an empty input -const makePatientsSuggestions = (set = [], userOptions = undefined) => { +const makePatientsSuggestions = ( + set = [], + userOptions?: Options, +) => { const $nin = list(map((x) => x._id, set)); return (searchString: string) => { const [debouncedSearchString, {isPending, cancel, flush}] = useDebounce( diff --git a/imports/ui/patients/usePatientPersonalInformationReducer.ts b/imports/ui/patients/usePatientPersonalInformationReducer.ts index a6d3eca58..bc9f07b7b 100644 --- a/imports/ui/patients/usePatientPersonalInformationReducer.ts +++ b/imports/ui/patients/usePatientPersonalInformationReducer.ts @@ -16,10 +16,15 @@ const initialState = (patient: PatientDocument): State => ({ deleting: false, }); -export const reducer = ( - state: State, - action: {type: string; key?: string; value?: any; payload?: any}, -) => { +type Action = + | {type: 'update'; key: string; value: any} + | {type: 'editing'} + | {type: 'not-editing'} + | {type: 'deleting'} + | {type: 'not-deleting'} + | {type: 'init'; payload: any}; + +export const reducer = (state: State, action: Action) => { switch (action.type) { case 'update': { switch (action.key) { @@ -72,6 +77,7 @@ export const reducer = ( } default: { + // @ts-expect-error This should never be called. throw new Error(`Unknown action type ${action.type}.`); } } diff --git a/imports/ui/planner/Planner.tsx b/imports/ui/planner/Planner.tsx index 788de659f..4ca5045df 100644 --- a/imports/ui/planner/Planner.tsx +++ b/imports/ui/planner/Planner.tsx @@ -13,12 +13,15 @@ import type PropsOf from '../../lib/types/PropsOf'; import usePlannerContextState from './usePlannerContextState'; import usePlannerNewAppointmentDialogState from './usePlannerNewAppointmentDialogState'; -type Props = { - Calendar: C extends React.ElementType ? C : never; - CalendarProps?: PropsOf; +type Props = { + Calendar: C; + CalendarProps: PropsOf; }; -const Planner = ({Calendar, CalendarProps}: Props) => { +const Planner = ({ + Calendar, + CalendarProps, +}: Props) => { const {patientId} = useParams<{patientId?: string}>(); const { diff --git a/imports/ui/planner/PlannerContext.ts b/imports/ui/planner/PlannerContext.ts index 2f796916d..ccc315c0b 100644 --- a/imports/ui/planner/PlannerContext.ts +++ b/imports/ui/planner/PlannerContext.ts @@ -1,7 +1,17 @@ import {createContext} from 'react'; -import {ALL_WEEK_DAYS} from '../../lib/datetime'; +import {ALL_WEEK_DAYS, type WeekDay} from '../../lib/datetime'; -const PlannerContext = createContext({ +type Context = { + showCancelledEvents: boolean; + toggleShowCancelledEvents?: () => void; + showNoShowEvents: boolean; + toggleShowNoShowEvents?: () => void; + displayAllWeekDays: boolean; + toggleDisplayAllWeekDays?: () => void; + displayedWeekDays: readonly WeekDay[]; +}; + +const PlannerContext = createContext({ showCancelledEvents: false, toggleShowCancelledEvents: undefined, showNoShowEvents: false, diff --git a/imports/ui/routes/paged.tsx b/imports/ui/routes/paged.tsx index 412524087..4a3703305 100644 --- a/imports/ui/routes/paged.tsx +++ b/imports/ui/routes/paged.tsx @@ -15,7 +15,10 @@ const PagedRoute = ({ props, }: BranchProps) => { const params = useParams(); - const page = parseNonNegativeIntegerStrictOrUndefined(params.page); + const page = + params.page === undefined + ? undefined + : parseNonNegativeIntegerStrictOrUndefined(params.page); return ; }; diff --git a/imports/ui/search/FullTextSearchInput.tsx b/imports/ui/search/FullTextSearchInput.tsx index 261333a5c..4178da48d 100644 --- a/imports/ui/search/FullTextSearchInput.tsx +++ b/imports/ui/search/FullTextSearchInput.tsx @@ -10,6 +10,7 @@ import {myEncodeURIComponent} from '../../lib/uri'; import SearchBox from '../input/SearchBox'; import {TIMEOUT_INPUT_DEBOUNCE} from '../constants'; +import type Timeout from '../../lib/types/Timeout'; const PREFIX = 'FullTextSearchInput'; @@ -36,7 +37,7 @@ const FullTextSearchInput = ({sx}) => { Number.NEGATIVE_INFINITY, ); const [value, setValue] = useState(''); - const [pending, setPending] = useState(undefined); + const [pending, setPending] = useState(undefined); const updateHistory = (newValue) => { clearTimeout(pending); diff --git a/imports/ui/settings/DisplayedWeekDaysSetting.tsx b/imports/ui/settings/DisplayedWeekDaysSetting.tsx index 3be4b3784..a0a2f272f 100644 --- a/imports/ui/settings/DisplayedWeekDaysSetting.tsx +++ b/imports/ui/settings/DisplayedWeekDaysSetting.tsx @@ -1,8 +1,6 @@ import React, {useMemo} from 'react'; -import {list} from '@iterable-iterator/list'; import {filter} from '@iterable-iterator/filter'; -import {range} from '@iterable-iterator/range'; import {sorted} from '@iterable-iterator/sorted'; import {key} from '@total-order/key'; @@ -14,6 +12,8 @@ import { type WeekStartsOn, } from '../../i18n/datetime'; +import {ALL_WEEK_DAYS, type WeekDay} from '../../lib/datetime'; +import {mod} from '../../lib/arithmetic'; import InputManySetting from './InputManySetting'; const KEY = 'displayed-week-days'; @@ -22,7 +22,7 @@ export default function DisplayedWeekDaysSetting({className}) { const weekStartsOn = useWeekStartsOn(); const compare = useMemo( - () => key(increasing, (x: WeekStartsOn) => (7 + x - weekStartsOn) % 7), + () => key(increasing, (x: WeekStartsOn) => mod(x - weekStartsOn, 7)), [weekStartsOn], ); @@ -31,17 +31,17 @@ export default function DisplayedWeekDaysSetting({className}) { [compare], ); - const options: WeekStartsOn[] = list(range(7)); + const options = ALL_WEEK_DAYS; const DAYS = useDaysNames(options); - const formatDayOfWeek = (i: WeekStartsOn) => DAYS[i]; + const formatDayOfWeek = (i: WeekDay) => DAYS.get(i)!; - const makeSuggestions = (value: WeekStartsOn[]) => (inputValue: string) => ({ + const makeSuggestions = (value: WeekDay[]) => (inputValue: string) => ({ results: sorted( compare, filter( - (i: WeekStartsOn) => + (i: WeekDay) => !value.includes(i) && formatDayOfWeek(i).toLowerCase().startsWith(inputValue.toLowerCase()), options, @@ -55,7 +55,7 @@ export default function DisplayedWeekDaysSetting({className}) { title="Displayed week days" label="Week days" setting={KEY} - itemToString={(x) => formatDayOfWeek(x)} + itemToString={(x: WeekDay) => formatDayOfWeek(x)} createNewItem={undefined} makeSuggestions={makeSuggestions} placeholder="Give additional week days" diff --git a/imports/ui/settings/FirstWeekContainsDateSetting.tsx b/imports/ui/settings/FirstWeekContainsDateSetting.tsx index 8febe626c..aa81bcacc 100644 --- a/imports/ui/settings/FirstWeekContainsDateSetting.tsx +++ b/imports/ui/settings/FirstWeekContainsDateSetting.tsx @@ -18,7 +18,7 @@ const FirstWeekContainsDateSetting = ({className}) => { const POSITIONS = useDaysPositions(positions); - const optionToString = (x) => + const optionToString = (x: string) => x === 'locale' ? `${POSITIONS[firstWeekContainsDate]} (same as locale)` : POSITIONS[x]; diff --git a/imports/ui/settings/LanguageSetting.tsx b/imports/ui/settings/LanguageSetting.tsx index b8bc716ed..215cade04 100644 --- a/imports/ui/settings/LanguageSetting.tsx +++ b/imports/ui/settings/LanguageSetting.tsx @@ -7,7 +7,7 @@ import {navigatorLocale} from '../../i18n/navigator'; import SelectOneSetting from './SelectOneSetting'; const options = ['navigator', ...availableLocales]; -const optionToString = (x) => +const optionToString = (x: string) => x === 'navigator' ? `${localeDescriptions[navigatorLocale()]} (same as navigator)` : localeDescriptions[x]; diff --git a/imports/ui/settings/SelectOneSetting.tsx b/imports/ui/settings/SelectOneSetting.tsx index 18b9a7a9a..9943aa76e 100644 --- a/imports/ui/settings/SelectOneSetting.tsx +++ b/imports/ui/settings/SelectOneSetting.tsx @@ -2,30 +2,31 @@ import React from 'react'; import Typography from '@mui/material/Typography'; +import {type SelectChangeEvent} from '@mui/material'; import ValuePicker from '../input/ValuePicker'; import {useSetting} from './hooks'; -type Props = { +type Props = { className?: string; title?: string; label?: string; setting: string; - options: string[]; - optionToString?: (option: string) => string; + options: T[]; + optionToString?: (option: T) => string; }; -const SelectOneSetting = ({ +const SelectOneSetting = ({ className, setting, options, optionToString, label, title, -}: Props) => { +}: Props) => { const {loading, value, setValue} = useSetting(setting); - const onChange = async (e) => { + const onChange = async (e: SelectChangeEvent) => { const newValue = e.target.value; await setValue(newValue); }; diff --git a/imports/ui/settings/WeekStartsOnSetting.tsx b/imports/ui/settings/WeekStartsOnSetting.tsx index 5b53e987e..977a7682c 100644 --- a/imports/ui/settings/WeekStartsOnSetting.tsx +++ b/imports/ui/settings/WeekStartsOnSetting.tsx @@ -1,22 +1,24 @@ import React from 'react'; -import {range} from '@iterable-iterator/range'; import {list} from '@iterable-iterator/list'; import {chain} from '@iterable-iterator/chain'; import {useDaysNames, useLocaleWeekStartsOn} from '../../i18n/datetime'; +import {ALL_WEEK_DAYS, type WeekDay} from '../../lib/datetime'; import SelectOneSetting from './SelectOneSetting'; -const days = list(range(7)); -const options = list(chain(['locale'], days)); +const days = ALL_WEEK_DAYS; +const options: Array<'locale' | WeekDay> = list(chain(['locale'], days)); const WeekStartsOnSetting = ({className}) => { const weekStartsOn = useLocaleWeekStartsOn(); const DAYS = useDaysNames(days); - const optionToString = (x) => - x === 'locale' ? `${DAYS[weekStartsOn]} (same as locale)` : DAYS[x]; + const optionToString = (x: 'locale' | WeekDay): string => + x === 'locale' + ? `${DAYS.get(weekStartsOn)!} (same as locale)` + : DAYS.get(x)!; return ( { const beginDay = Math.floor( (beginModuloWeek % durationUnits.week) / durationUnits.day, - ); + ) as WeekStartsOn; const beginModuloDay = beginModuloWeek % durationUnits.day; const beginModuloHour = beginModuloWeek % durationUnits.hour; const beginHour = Math.floor(beginModuloDay / durationUnits.hour); const beginMinutes = Math.floor(beginModuloHour / durationUnits.minute); const endDay = Math.floor( (endModuloWeek % durationUnits.week) / durationUnits.day, - ); + ) as WeekStartsOn; const endModuloDay = endModuloWeek % durationUnits.day; const endModuloHour = endModuloWeek % durationUnits.hour; const endHour = Math.floor(endModuloDay / durationUnits.hour); @@ -44,7 +48,10 @@ const moduloWeekIntervalToInterval = ({ const useTimeSlotFormat = () => { const DAYS = useDaysOfWeek(); const timeFormat = useDateFormat('p'); - const formatDayOfWeek = useCallback((i) => DAYS[i], DAYS); + const formatDayOfWeek = useCallback( + (i: WeekStartsOn) => DAYS.get(i), + [...DAYS.keys()], + ); return useCallback( ({ diff --git a/imports/ui/stats/Age.tsx b/imports/ui/stats/Age.tsx index cd7287cb8..e666cb735 100644 --- a/imports/ui/stats/Age.tsx +++ b/imports/ui/stats/Age.tsx @@ -13,7 +13,7 @@ const Chart = (props) => { const data = loading ? [] - : Object.entries(count) + : Object.entries(count ?? {}) .map(([key, value]) => ({ key, female: 0, diff --git a/imports/ui/stats/Frequency.tsx b/imports/ui/stats/Frequency.tsx index 75cca01a3..e54a6f746 100644 --- a/imports/ui/stats/Frequency.tsx +++ b/imports/ui/stats/Frequency.tsx @@ -12,7 +12,7 @@ const Chart = (props) => { const data = loading ? [] - : Object.entries(count).map(([key, value]) => ({ + : Object.entries(count ?? {}).map(([key, value]) => ({ key, female: value.female ?? 0, male: value.male ?? 0, diff --git a/imports/ui/stats/Sex.tsx b/imports/ui/stats/Sex.tsx index 2af7e73c4..5e52d7bd1 100644 --- a/imports/ui/stats/Sex.tsx +++ b/imports/ui/stats/Sex.tsx @@ -46,15 +46,22 @@ const Chart = ({width, height}) => { const handleMouseMove = (datum) => (event) => { const coords = localPoint(event.target.ownerSVGElement, event); - showTooltip({ - tooltipLeft: coords.x, - tooltipTop: coords.y, - tooltipData: datum, - }); + if (coords !== null) { + showTooltip({ + tooltipLeft: coords.x, + tooltipTop: coords.y, + tooltipData: datum, + }); + } }; const radius = Math.min(width, height) / 2; - const sex = []; + const sex: Array<{ + sex: string; + label?: string; + count?: number; + freq: number; + }> = []; const loadingText = 'Loading...'; const noDataText = 'No data'; if (loading) { @@ -63,10 +70,10 @@ const Chart = ({width, height}) => { sex.push({sex: noDataText, freq: 1}); } else { for (const s of ['female', 'male', 'other', '', 'undefined']) { - if (count[s]) { + if (count?.[s]) { sex.push({ sex: s || 'none', - label: (s || 'none')[0].toUpperCase(), + label: (s || 'none').slice(0, 1).toUpperCase(), count: count[s], freq: count[s] / allCount, }); @@ -113,7 +120,7 @@ const Chart = ({width, height}) => { const {sex, label} = arc.data; const [centroidX, centroidY] = pie.path.centroid(arc); const hasSpaceForLabel = true; // Arc.endAngle - arc.startAngle >= 0.1; - const arcPath = pie.path(arc); + const arcPath = pie.path(arc) ?? undefined; const arcFill = ordinalColorScale(sex); return ( @@ -135,13 +142,17 @@ const Chart = ({width, height}) => { - {!loading && allCount > 0 && tooltipOpen && ( - - {`${tooltipData.sex}: ${tooltipData.count} (${( - tooltipData.freq * 100 - ).toPrecision(4)}%)`} - - )} + {!loading && + allCount !== undefined && + allCount > 0 && + tooltipOpen && + tooltipData && ( + + {`${tooltipData.sex}: ${tooltipData.count} (${( + tooltipData.freq * 100 + ).toPrecision(4)}%)`} + + )} ); }; diff --git a/imports/ui/stats/StackedBarChart.tsx b/imports/ui/stats/StackedBarChart.tsx index 604016935..3af126ecf 100644 --- a/imports/ui/stats/StackedBarChart.tsx +++ b/imports/ui/stats/StackedBarChart.tsx @@ -122,7 +122,7 @@ const StackedBarChart = ({width, height, margin, data, color}: Props) => { width={bar.width} fill={bar.color} onMouseLeave={() => { - tooltipTimeout = window.setTimeout(() => { + tooltipTimeout = setTimeout(() => { hideTooltip(); }, 300); }} @@ -174,7 +174,7 @@ const StackedBarChart = ({width, height, margin, data, color}: Props) => { /> )}
- {tooltipOpen && ( + {tooltipOpen && tooltipData && ( {`${tooltipData.key}: ${ tooltipData.bar.data[tooltipData.key] diff --git a/imports/ui/stats/useAgeStats.ts b/imports/ui/stats/useAgeStats.ts index a72011ba0..6e107aa3f 100644 --- a/imports/ui/stats/useAgeStats.ts +++ b/imports/ui/stats/useAgeStats.ts @@ -1,6 +1,6 @@ import {Patients} from '../../api/collection/patients'; import makeHistogram from './makeHistogram'; -const useAgeStats = makeHistogram(Patients, ['key', 'sex']); +const useAgeStats = makeHistogram>(Patients, ['key', 'sex']); export default useAgeStats; diff --git a/imports/ui/stats/useSexStats.ts b/imports/ui/stats/useSexStats.ts index 43cf7c31a..4a9287c0c 100644 --- a/imports/ui/stats/useSexStats.ts +++ b/imports/ui/stats/useSexStats.ts @@ -1,6 +1,6 @@ import {Patients} from '../../api/collection/patients'; import makeHistogram from './makeHistogram'; -const useSexStats = makeHistogram(Patients, ['sex']); +const useSexStats = makeHistogram(Patients, ['sex']); export default useSexStats; diff --git a/imports/ui/tags/TagList.tsx b/imports/ui/tags/TagList.tsx index e2170dd8f..0951e60e4 100644 --- a/imports/ui/tags/TagList.tsx +++ b/imports/ui/tags/TagList.tsx @@ -11,7 +11,10 @@ const TagList = ( props: TagListProps, ) => { const params = useParams<{page?: string}>(); - const page = parseNonNegativeIntegerStrictOrUndefined(params.page); + const page = + params.page === undefined + ? undefined + : parseNonNegativeIntegerStrictOrUndefined(params.page); return ; }; diff --git a/imports/ui/tags/TagListPage.tsx b/imports/ui/tags/TagListPage.tsx index 33b266416..d469839b5 100644 --- a/imports/ui/tags/TagListPage.tsx +++ b/imports/ui/tags/TagListPage.tsx @@ -22,7 +22,7 @@ export type TagListPageProps = { query?: Selector; sort?: {}; - useTags?: GenericQueryHook; + useTags: GenericQueryHook; }; const TagListPage = ({ diff --git a/imports/ui/text/colorizeText.tsx b/imports/ui/text/colorizeText.tsx index 9960b018b..ec4954957 100644 --- a/imports/ui/text/colorizeText.tsx +++ b/imports/ui/text/colorizeText.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {type ReactElement} from 'react'; import {enumerate} from '@iterable-iterator/zip'; @@ -33,13 +33,13 @@ function* split(s: string) { } export default function colorizeText( - matches: (piece: string) => boolean, + matches: null | ((piece: string) => boolean), text: string, ) { if (!text) return []; if (!matches) return [text]; - const result = []; + const result: Array = []; for (const [i, piece] of enumerate(split(text))) { if (matches(piece)) { result.push({piece}); diff --git a/imports/ui/users/ChangePasswordPopover.tsx b/imports/ui/users/ChangePasswordPopover.tsx index 4137a529c..8cd802cf1 100644 --- a/imports/ui/users/ChangePasswordPopover.tsx +++ b/imports/ui/users/ChangePasswordPopover.tsx @@ -10,7 +10,7 @@ import {Popover, Form, RowTextField, RowButton} from './Popover'; type Props = { id: string; - anchorEl: HTMLElement; + anchorEl?: HTMLElement | null; handleClose: () => void; }; diff --git a/imports/ui/users/Dashboard.tsx b/imports/ui/users/Dashboard.tsx index 97500a90d..42ae29c67 100644 --- a/imports/ui/users/Dashboard.tsx +++ b/imports/ui/users/Dashboard.tsx @@ -83,7 +83,19 @@ const OfflineOnlineToggle = ({onSuccess}: OfflineOnlineToggleProps) => { } }; -const OptionsPopover = ({id, anchorEl, handleClose, changeMode}) => { +type OptionsPopoverProps = { + id: string; + anchorEl?: HTMLElement | null; + handleClose: () => void; + changeMode: (mode: string) => void; +}; + +const OptionsPopover = ({ + id, + anchorEl, + handleClose, + changeMode, +}: OptionsPopoverProps) => { const handleModeChangePassword = () => { changeMode('change-password'); }; @@ -104,7 +116,7 @@ const OptionsPopover = ({id, anchorEl, handleClose, changeMode}) => { const Dashboard = ({currentUser}) => { const [mode, setMode] = useState('options'); - const [anchorEl, setAnchorEl] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); const handleClick = (event) => { setMode('options'); @@ -131,7 +143,7 @@ const Dashboard = ({currentUser}) => { ? mode === 'options' ? 'dashboard-options' : 'dashboard-change-password' - : null + : undefined } aria-haspopup="true" aria-expanded={anchorEl ? 'true' : undefined} diff --git a/imports/ui/users/SignInForm.tsx b/imports/ui/users/SignInForm.tsx index 8741022cb..9f484fd2f 100644 --- a/imports/ui/users/SignInForm.tsx +++ b/imports/ui/users/SignInForm.tsx @@ -7,7 +7,7 @@ import LoginPopover from './LoginPopover'; import RegisterPopover from './RegisterPopover'; const SignInForm = () => { - const [anchorEl, setAnchorEl] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); const [mode, setMode] = useState('choice'); const handleClick = (event) => { @@ -31,7 +31,7 @@ const SignInForm = () => { ? mode === 'login' ? 'login-popover' : 'register-popover' - : null + : undefined } aria-haspopup="true" style={{color: 'inherit'}} diff --git a/test/app/client/document/upload.app-tests.ts b/test/app/client/document/upload.app-tests.ts index e672ebf87..3a1b384da 100644 --- a/test/app/client/document/upload.app-tests.ts +++ b/test/app/client/document/upload.app-tests.ts @@ -23,7 +23,7 @@ const createFileList = (files: File[]): FileList => { const list: FileList & Iterable = { ...files, length: files.length, - item: (index: number) => list[index], + item: (index: number) => list[index]!, [Symbol.iterator]: () => files[Symbol.iterator](), }; list.constructor = FileList; @@ -55,7 +55,7 @@ const createItemList = (files: File[]): DataTransferItemList => { *[Symbol.iterator]() { // eslint-disable-next-line unicorn/no-for-loop,@typescript-eslint/prefer-for-of for (let i = 0; i < list.length; ++i) { - yield list[i]; + yield list[i]!; } }, };