From 8bc93331b6217e0c719619135962bc05fc44c1dd Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 12:36:20 +0100 Subject: [PATCH 01/12] TimeEntry: add parseMany method --- app/schema/TimeEntry.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/app/schema/TimeEntry.ts b/app/schema/TimeEntry.ts index 08faa562..c88c165e 100644 --- a/app/schema/TimeEntry.ts +++ b/app/schema/TimeEntry.ts @@ -9,6 +9,7 @@ import { LocalDateFromString, LocalDateSchema } from "./LocalDate" import { type Project } from "./Project" import { ProjectCollection, ProvidedProjects } from "./ProjectCollection" import { ProjectFromId } from "./ProjectFromId" +import { parseTimeEntry } from "./lib/parseTimeEntry" // eslint-disable-line import/no-cycle import { withDefault, withDefaultId } from "./lib/withDefault" export const TimeEntryId = pipe(Cuid, S.brand("TimeEntryId")) @@ -35,7 +36,7 @@ export class TimeEntry extends S.Class("TimeEntry")({ }) { static decode = ( encoded: TimeEntryEncoded, - { projects, clients }: { projects: Project[]; clients: Client[] }, + { projects, clients }: { projects: Project[]; clients: Client[] }, // TODO: always pass collections, not arrays ) => pipe( encoded, @@ -57,6 +58,35 @@ export class TimeEntry extends S.Class("TimeEntry")({ $, stripUndefined, ) + + /** + * Takes an input string consisting of one or more lines, and attempts to parse it into one or + * more entries. Returns `[errors, parsedEntries]`. */ + static parseMany = ({ + input: multilineInput, + contactId, + date, + projects, + clients, + }: TimeEntryInput & { + projects: ProjectCollection + clients: ClientCollection + }) => { + const parse = (input: string) => + pipe( + { input, contactId, date }, + parseTimeEntry, + E.provideService(ProvidedProjects, projects), + E.provideService(ProvidedClients, clients), + ) + + return pipe( + multilineInput, + s => s.split("\n"), + E.partition(parse), // try to parse each line + $, + ) + } } export type TimeEntryEncoded = typeof TimeEntry.Encoded From f9785d55a3bc33f00e39dd5644644a1ad3767233 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 12:36:52 +0100 Subject: [PATCH 02/12] parseTimeEntry: rewrite errors here (rather than in ui component) --- app/schema/lib/parseTimeEntry.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/schema/lib/parseTimeEntry.ts b/app/schema/lib/parseTimeEntry.ts index 94d68aee..3b9c7a94 100644 --- a/app/schema/lib/parseTimeEntry.ts +++ b/app/schema/lib/parseTimeEntry.ts @@ -1,7 +1,7 @@ import { createId } from "@paralleldrive/cuid2" import { Data, E } from "lib/Effect" import type { Project } from "schema/Project" -import { TimeEntry, TimeEntryId, type TimeEntryInput } from "../TimeEntry" +import { TimeEntry, TimeEntryId, type TimeEntryInput } from "../TimeEntry" // eslint-disable-line import/no-cycle import { parseClient } from "./parseClient" import { parseDuration } from "./parseDuration" import { parseProject } from "./parseProject" @@ -33,7 +33,13 @@ export const parseTimeEntry = ({ ) return new TimeEntry({ id, contactId, date, duration, project, client, description, input }) - }) + }).pipe( + E.mapError(error => + error._tag === "KeyNotFound" ? + { ...error, message: `The client @${error.value} wasn't found.` } + : error, + ), + ) const collapseWhitespace = (s: string) => s.replaceAll(/\s+/g, " ").trim() From 3aa28060ca032382578bea03df4941d86bcd7ca2 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 12:38:37 +0100 Subject: [PATCH 03/12] TimeEntryInput: use TimeEntry.parseMany --- app/ui/TimeEntryInput.tsx | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/app/ui/TimeEntryInput.tsx b/app/ui/TimeEntryInput.tsx index 21a1bfcc..95c760f8 100644 --- a/app/ui/TimeEntryInput.tsx +++ b/app/ui/TimeEntryInput.tsx @@ -84,37 +84,31 @@ export const TimeEntryInput = ({ { enableOnFormTags: true }, ) - /** - * Given an input string, attempts to parse it into a time entry. - * Returns an effect. - */ - const parse = (input: string) => - parseTimeEntry({ contactId, date, input }).pipe( - // rewrite some error messages - E.mapError(error => - error._tag === "KeyNotFound" ? - { ...error, message: `The client @${error.value} wasn't found.` } - : error, - ), - E.provideService(ProvidedProjects, projects), - E.provideService(ProvidedClients, clients), - ) - /** * Given a (potentially multiline) input string, attempts to parse into one or more time entries. - * Records any errors in states, and if there are none commits the entries. If the input is empty, - * the entry is destroyed. + * Records any errors, and if there are none commits the entries. If the input is empty, the entry + * is destroyed. */ const commit = (content: string) => { content = content.trim() + + // empty content means the entry should be removed if (content.length === 0) { - // empty content means the entry should be removed onDestroy() } else { // process each line as a separate entry - const lines = content.split("\n") - const [errors, parsedEntries] = $(E.partition(lines, input => parse(input))) + const [errors, parsedEntries] = TimeEntry.parseMany({ + input: content, + contactId, + date, + projects, + clients, + }) + + // errors will be displayed in the popover setErrors(errors) + + // only commit if there are no errors if (errors.length === 0) { for (const entry of parsedEntries) { onCommit(entry) From cc6b7ef288c56d0e3b2a2cd29787e686b373b579 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 12:38:49 +0100 Subject: [PATCH 04/12] TimeEntryInput: minor refactoring & cleanup --- app/ui/TimeEntryInput.tsx | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/app/ui/TimeEntryInput.tsx b/app/ui/TimeEntryInput.tsx index 95c760f8..bc869869 100644 --- a/app/ui/TimeEntryInput.tsx +++ b/app/ui/TimeEntryInput.tsx @@ -2,15 +2,13 @@ import type { LocalDate } from "@js-joda/core" import { Popover, PopoverAnchor, PopoverArrow, PopoverContent } from "@ui/popover" import { NO_OP } from "lib/constants" import { cx } from "lib/cx" -import { $, E } from "lib/Effect" import { Keys } from "lib/keys" import { useEffect, useState } from "react" import { useHotkeys } from "react-hotkeys-hook" -import { ProvidedClients, type ClientCollection } from "schema/ClientCollection" +import { type ClientCollection } from "schema/ClientCollection" import type { Contact } from "schema/Contact" -import { parseTimeEntry } from "schema/lib/parseTimeEntry" -import { ProvidedProjects, type ProjectCollection } from "schema/ProjectCollection" -import type { TimeEntry } from "schema/TimeEntry" +import { type ProjectCollection } from "schema/ProjectCollection" +import { TimeEntry } from "schema/TimeEntry" import { AutocompleteTextarea } from "./AutocompleteTextarea" const { enter, escape, up, down, left, right } = Keys @@ -58,20 +56,20 @@ export const TimeEntryInput = ({ const textareaRef = useHotkeys( [enter, escape, up, down, left, right], (e, { keys = [] }) => { - const key = keys.join("") - if (!textareaRef.current) return const textarea = textareaRef.current - const { value, selectionStart } = textarea + if (!textarea) return + const { value, textContent, selectionStart } = textarea + const key = keys.join("") if (key === escape) { setNewContent(content) // restore the original content setErrors([]) onDiscard() - // yield a tick to let the content be restored and then blur + // yield a tick to let the content be restored, and then blur setTimeout(() => textarea.blur(), 1) } else if (key === enter) { e.preventDefault() - commit(textareaRef.current.textContent ?? "") + commit(textContent ?? "") } else if (!autocompleteOpen) { // only handle arrow keys if we're not in an autocomplete query if (key === up && selectionStart === 0) { @@ -140,11 +138,9 @@ export const TimeEntryInput = ({ aria-invalid={showError} aria-errormessage={errorMessageId} onFocus={() => onFocus(index)} - onBlur={e => { - commit(e.target.value) - }} + onBlur={e => commit(e.target.value)} onChange={(value: string) => { - setErrors([]) // don't keep errors around of the user starts typing again + setErrors([]) // don't keep errors around if the user starts typing again setNewContent(value) }} onOpen={() => setAutocompleteOpen(true)} From 1fefcbdd391c93cff7d5ec529034e0f89e36263f Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 12:49:56 +0100 Subject: [PATCH 05/12] move reconstructTimeEntryInput to own file --- app/lib/csvToTimeEntries.ts | 4 +-- app/lib/generateTimeEntries.ts | 5 ++-- app/schema/Project.ts | 25 ------------------- app/schema/lib/reconstructTimeEntryInput.ts | 27 +++++++++++++++++++++ 4 files changed, 32 insertions(+), 29 deletions(-) create mode 100644 app/schema/lib/reconstructTimeEntryInput.ts diff --git a/app/lib/csvToTimeEntries.ts b/app/lib/csvToTimeEntries.ts index 46f53045..b86c2141 100644 --- a/app/lib/csvToTimeEntries.ts +++ b/app/lib/csvToTimeEntries.ts @@ -2,7 +2,7 @@ import { createId } from "@paralleldrive/cuid2" import { Data, E, S } from "lib/Effect" import { ProvidedClients } from "schema/ClientCollection" import { ProvidedContacts } from "schema/ContactCollection" -import { reconstructInput } from "schema/Project" +import { reconstructTimeEntryInput } from "schema/lib/reconstructTimeEntryInput" import { ProvidedProjects } from "schema/ProjectCollection" import { TimeEntry } from "schema/TimeEntry" import { csvToSchema } from "./parseCsv" @@ -41,7 +41,7 @@ export const csvToTimeEntries = (csvData: string) => client: client?.id, contactId: contact.id, timestamp: Date.now(), - input: reconstructInput({ + input: reconstructTimeEntryInput({ ...row, durationInHours: row.duration, project: project.fullCode, diff --git a/app/lib/generateTimeEntries.ts b/app/lib/generateTimeEntries.ts index 6f3c2ae5..fb09b081 100644 --- a/app/lib/generateTimeEntries.ts +++ b/app/lib/generateTimeEntries.ts @@ -3,7 +3,8 @@ import { type LocalDate } from "@js-joda/core" import { dummyDones } from "data/dummyDones" import type { Client } from "schema/Client" import type { Contact, ContactId } from "schema/Contact" -import { reconstructInput, type Project } from "schema/Project" +import { type Project } from "schema/Project" +import { reconstructTimeEntryInput } from "schema/lib/reconstructTimeEntryInput" import { TimeEntry } from "schema/TimeEntry" import { getWorkDays } from "./getWorkDays" @@ -64,7 +65,7 @@ export function generateTimeEntries({ const project = random.pick(projects) const maybeClient = project.requiresClient ? { client: random.pick(clients) } : {} const description = random.probability(0.05) ? random.pick(dummyDones) : "" - const input = reconstructInput({ + const input = reconstructTimeEntryInput({ durationInHours: duration / 60, project: project.fullCode, client: maybeClient.client?.code, diff --git a/app/schema/Project.ts b/app/schema/Project.ts index 4ea9db80..230862f1 100644 --- a/app/schema/Project.ts +++ b/app/schema/Project.ts @@ -1,5 +1,4 @@ import { pipe, S } from "lib/Effect" -import { formatDuration } from "lib/formatDuration" import { stripUndefined } from "lib/stripUndefined" import { Cuid } from "./Cuid" import { withDefault, withDefaultId } from "./lib/withDefault" @@ -33,27 +32,3 @@ export const makeFullCode = (code: string, subCode?: string) => subCode ? `${code}:${subCode}` : code export type ProjectEncoded = typeof Project.Encoded -/** - * Constructs a text input that could be parsed to recreate this entry. This is what is used - * when the entry is edited. - */ - -export const reconstructInput = ({ - durationInHours, - project, - client, - description, -}: { - durationInHours: number - project: string - client: string | undefined - description?: string -}) => - [ - `${formatDuration(durationInHours * 60)}`, // duration in hours - `#${project}`, // project code - client ? `@${client}` : undefined, // (maybe) client code - description, - ] - .filter(Boolean) - .join(" ") diff --git a/app/schema/lib/reconstructTimeEntryInput.ts b/app/schema/lib/reconstructTimeEntryInput.ts new file mode 100644 index 00000000..703f0431 --- /dev/null +++ b/app/schema/lib/reconstructTimeEntryInput.ts @@ -0,0 +1,27 @@ +import { formatDuration } from "lib/formatDuration" + +/** + * Constructs a text input that could be parsed to recreate this entry. We use this in situations + * where there was no human-entered input - for example when generating or importing entries. We + * need this to be able to show put something in the input field when the entry is edited. + */ + +export const reconstructTimeEntryInput = ({ + durationInHours, + project, + client, + description, +}: { + durationInHours: number + project: string + client: string | undefined + description?: string +}) => + [ + `${formatDuration(durationInHours * 60)}`, // duration in hours + `#${project}`, // project code + client ? `@${client}` : undefined, // (maybe) client code + description, + ] + .filter(Boolean) + .join(" ") From d11656168d53d37b747b1a876c660ae132d63607 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 13:00:41 +0100 Subject: [PATCH 06/12] disable import/no-cycle --- app/schema/TimeEntry.ts | 2 +- app/schema/lib/parseTimeEntry.ts | 2 +- xo.config.cjs | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/schema/TimeEntry.ts b/app/schema/TimeEntry.ts index c88c165e..41a7a000 100644 --- a/app/schema/TimeEntry.ts +++ b/app/schema/TimeEntry.ts @@ -9,7 +9,7 @@ import { LocalDateFromString, LocalDateSchema } from "./LocalDate" import { type Project } from "./Project" import { ProjectCollection, ProvidedProjects } from "./ProjectCollection" import { ProjectFromId } from "./ProjectFromId" -import { parseTimeEntry } from "./lib/parseTimeEntry" // eslint-disable-line import/no-cycle +import { parseTimeEntry } from "./lib/parseTimeEntry" import { withDefault, withDefaultId } from "./lib/withDefault" export const TimeEntryId = pipe(Cuid, S.brand("TimeEntryId")) diff --git a/app/schema/lib/parseTimeEntry.ts b/app/schema/lib/parseTimeEntry.ts index 3b9c7a94..67df14f3 100644 --- a/app/schema/lib/parseTimeEntry.ts +++ b/app/schema/lib/parseTimeEntry.ts @@ -1,7 +1,7 @@ import { createId } from "@paralleldrive/cuid2" import { Data, E } from "lib/Effect" import type { Project } from "schema/Project" -import { TimeEntry, TimeEntryId, type TimeEntryInput } from "../TimeEntry" // eslint-disable-line import/no-cycle +import { TimeEntry, TimeEntryId, type TimeEntryInput } from "../TimeEntry" import { parseClient } from "./parseClient" import { parseDuration } from "./parseDuration" import { parseProject } from "./parseProject" diff --git a/xo.config.cjs b/xo.config.cjs index 628ebf9a..0df43681 100644 --- a/xo.config.cjs +++ b/xo.config.cjs @@ -38,6 +38,7 @@ module.exports = { "ban-types": OFF, // deprecated "capitalized-comments": OFF, // case in point this comment "default-case": OFF, // conflicts with the superior @typescript-eslint/switch-exhaustiveness-check" + "import/no-cycle": OFF, // this artificially prevents us from modularizing some code, e.g. `TimeEntry` and `parseTimeEntry` would have to be in the same file "n/file-extension-in-import": OFF, // duplicate of import/extensions "n/prefer-global/process": OFF, // not helpful for browser code "new-cap": OFF, // @effect/schema has things like `S.Class` and `Context.Tag` that aren't constructors From 5790b746daf3c0dc3453c278ca712b3cf59d6f87 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 12:50:50 +0100 Subject: [PATCH 07/12] in decode functions, pass collections (rather than arrays) as dependencies --- app/schema/DoneEntry.ts | 10 +++++----- app/schema/TimeEntry.ts | 11 +++++------ app/schema/test/DoneEntry.test.ts | 3 ++- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/schema/DoneEntry.ts b/app/schema/DoneEntry.ts index 7ee070d4..44be23f6 100644 --- a/app/schema/DoneEntry.ts +++ b/app/schema/DoneEntry.ts @@ -1,6 +1,6 @@ import { $, E, pipe, S } from "lib/Effect" import { stripUndefined } from "lib/stripUndefined" -import { ContactId, type Contact } from "schema/Contact" +import { ContactId } from "schema/Contact" import { Cuid } from "schema/Cuid" import { LocalDateFromString } from "schema/LocalDate" import type { Optional } from "types/types" @@ -19,11 +19,11 @@ export class DoneEntry extends S.Class("DoneEntry")({ likes: S.optionalWith(S.Array(ContactFromId), { default: () => [], exact: true }), timestamp: S.optionalWith(S.DateFromNumber, { default: () => new Date(), exact: true }), }) { - static decode = (encoded: DoneEntryEncoded, { contacts }: { contacts: Contact[] }) => + static decode = (encoded: DoneEntryEncoded, { contacts }: { contacts: ContactCollection }) => pipe( - encoded, + encoded, // S.decode(DoneEntry), - E.provideService(ProvidedContacts, new ContactCollection(contacts)), + E.provideService(ProvidedContacts, contacts), $, ) @@ -31,7 +31,7 @@ export class DoneEntry extends S.Class("DoneEntry")({ pipe( decoded, // S.encode(DoneEntry), - E.provideService(ProvidedContacts, new ContactCollection([] as Contact[])), + E.provideService(ProvidedContacts, new ContactCollection()), $, stripUndefined, ) diff --git a/app/schema/TimeEntry.ts b/app/schema/TimeEntry.ts index 41a7a000..03137537 100644 --- a/app/schema/TimeEntry.ts +++ b/app/schema/TimeEntry.ts @@ -1,12 +1,10 @@ import { $, E, pipe, S } from "lib/Effect" import { stripUndefined } from "lib/stripUndefined" -import { type Client } from "./Client" import { ClientCollection, ProvidedClients } from "./ClientCollection" import { ClientFromId } from "./ClientFromId" import { ContactId } from "./Contact" import { Cuid } from "./Cuid" import { LocalDateFromString, LocalDateSchema } from "./LocalDate" -import { type Project } from "./Project" import { ProjectCollection, ProvidedProjects } from "./ProjectCollection" import { ProjectFromId } from "./ProjectFromId" import { parseTimeEntry } from "./lib/parseTimeEntry" @@ -36,13 +34,13 @@ export class TimeEntry extends S.Class("TimeEntry")({ }) { static decode = ( encoded: TimeEntryEncoded, - { projects, clients }: { projects: Project[]; clients: Client[] }, // TODO: always pass collections, not arrays + { projects, clients }: { projects: ProjectCollection; clients: ClientCollection }, ) => pipe( encoded, S.decode(TimeEntry), - E.provideService(ProvidedProjects, new ProjectCollection(projects)), - E.provideService(ProvidedClients, new ClientCollection(clients)), + E.provideService(ProvidedProjects, projects), + E.provideService(ProvidedClients, clients), $, ) @@ -61,7 +59,8 @@ export class TimeEntry extends S.Class("TimeEntry")({ /** * Takes an input string consisting of one or more lines, and attempts to parse it into one or - * more entries. Returns `[errors, parsedEntries]`. */ + * more entries. Returns `[errors, parsedEntries]`. + */ static parseMany = ({ input: multilineInput, contactId, diff --git a/app/schema/test/DoneEntry.test.ts b/app/schema/test/DoneEntry.test.ts index d9b21044..026a461a 100644 --- a/app/schema/test/DoneEntry.test.ts +++ b/app/schema/test/DoneEntry.test.ts @@ -1,5 +1,6 @@ import { LocalDate } from "@js-joda/core" import { contacts } from "data/contacts" +import { ContactCollection } from "schema/ContactCollection" import { describe, expect, expectTypeOf, it } from "vitest" import type { ContactId } from "../Contact" import { DoneEntry, type DoneEntryEncoded } from "../DoneEntry" @@ -49,7 +50,7 @@ describe("DoneEntry", () => { expectTypeOf(encoded).toMatchTypeOf() // round trip - const decodedAgain = DoneEntry.decode(encoded, { contacts }) + const decodedAgain = DoneEntry.decode(encoded, { contacts: new ContactCollection(contacts) }) expect(decodedAgain).toEqual(decoded) }) }) From ca3a1f77e3693cb2e6ba1dbae83955de836601b7 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 13:20:53 +0100 Subject: [PATCH 08/12] Replace $ with E.runSync --- app/context/Database/Collection.ts | 6 +- .../Database/test/Collection.benchmark.ts | 4 +- app/context/Database/test/Collection.test.ts | 54 +++++++------- app/context/Database/test/Indexes.test.ts | 74 +++++++++---------- app/hooks/useInvitation.tsx | 4 +- app/hooks/useMemberInvitationGenerator.tsx | 4 +- app/lib/Effect.ts | 4 - app/lib/runTestCases.ts | 4 +- app/lib/test/csvToDoneEntries.test.ts | 4 +- app/lib/test/csvToTimeEntries.test.ts | 4 +- .../_private+/devtools+/danger+/_danger.tsx | 34 ++++----- .../danger+/ui/DoneEntryImporter.tsx | 4 +- .../danger+/ui/TimeEntryImporter.tsx | 4 +- app/routes/_private+/dones+/$date.tsx | 6 +- app/routes/_private+/hours+/$year.tsx | 4 +- .../_private+/team+/members+/invite.tsx | 4 +- .../_private+/team+/members+/remove.tsx | 4 +- app/schema/Contact.ts | 6 +- app/schema/DoneEntry.ts | 6 +- app/schema/Root.ts | 6 +- app/schema/TimeEntry.ts | 8 +- app/schema/test/TimeEntry.test.ts | 8 +- app/ui/AutocompleteMenu.tsx | 4 +- app/ui/DailyDones.tsx | 10 +-- app/ui/DailyTimeEntries.tsx | 12 +-- app/ui/HoursReport.tsx | 12 +-- app/ui/MyWeek.tsx | 8 +- app/ui/stories/HoursReport.stories.tsx | 4 +- 28 files changed, 151 insertions(+), 155 deletions(-) diff --git a/app/context/Database/Collection.ts b/app/context/Database/Collection.ts index c34bc824..c0c383a5 100644 --- a/app/context/Database/Collection.ts +++ b/app/context/Database/Collection.ts @@ -1,6 +1,6 @@ import type { ChangeFn } from "@automerge/automerge" import { NO_OP } from "lib/constants" -import { $, E, type Types } from "lib/Effect" +import { E, type Types } from "lib/Effect" import type { Root, RootEncoded } from "schema/Root" import type { KeyNotFoundError } from "./Errors" import { NonUniqueIndex, UniqueIndex } from "./Indexes" @@ -67,7 +67,7 @@ export abstract class Collection< constructor( /** The initial items to populate the collection with */ - items: Item[] | Record, + items: Item[] | Record = [], /** * The automerge-repo change function returned by useDoc. @@ -207,7 +207,7 @@ export abstract class Collection< // create the index on demand if (!(name in indexes)) { - const items = $(this.all()) + const items = E.runSync(this.all()) const index = unique ? new UniqueIndex(items, key, name) // diff --git a/app/context/Database/test/Collection.benchmark.ts b/app/context/Database/test/Collection.benchmark.ts index 92a732a4..b272a8ea 100644 --- a/app/context/Database/test/Collection.benchmark.ts +++ b/app/context/Database/test/Collection.benchmark.ts @@ -2,7 +2,7 @@ import { LocalDate } from "@js-joda/core" import { clients } from "data/clients" import { contacts } from "data/contacts" import { projects } from "data/projects" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { generateTimeEntries } from "lib/generateTimeEntries" import { TimeEntryCollection } from "schema/TimeEntryCollection" import { bench, describe } from "vitest" @@ -17,7 +17,7 @@ const addEntries = (weekCount: number) => { }) const collection = new TimeEntryCollection([]) - $(collection.add(entries)) + E.runSync(collection.add(entries)) } describe("collection.add()", () => { diff --git a/app/context/Database/test/Collection.test.ts b/app/context/Database/test/Collection.test.ts index a91645df..8f7258f5 100644 --- a/app/context/Database/test/Collection.test.ts +++ b/app/context/Database/test/Collection.test.ts @@ -1,6 +1,6 @@ import { LocalDate } from "@js-joda/core" import { contacts } from "data/contacts" -import { $, E, Either } from "lib/Effect" +import { E, Either } from "lib/Effect" import { generateDones } from "lib/generateDones" import { Contact } from "schema/Contact" import { DoneEntry } from "schema/DoneEntry" @@ -56,7 +56,7 @@ describe("Collection", () => { describe("constructor", () => { it("instantiates the collection", () => { const { collection } = setup() - expect($(collection.all())).toHaveLength(14) + expect(E.runSync(collection.all())).toHaveLength(14) }) }) @@ -64,7 +64,7 @@ describe("Collection", () => { it("finds an item by ID", () => { const { dones, collection } = setup() const { id } = dones[0] - const item = $(collection.find(id)) + const item = E.runSync(collection.find(id)) expect(item).toEqual(dones[0]) }) @@ -77,19 +77,19 @@ describe("Collection", () => { it("finds an item by a non-unique indexed property", () => { const { collection } = setup() - const items = $(collection.findBy("contactId", alice.id)) + const items = E.runSync(collection.findBy("contactId", alice.id)) expect(items).toHaveLength(7) }) it("finds an item using an accessor function", () => { const { collection } = setup() - const items = $(collection.findBy("week", "2024-11-03")) + const items = E.runSync(collection.findBy("week", "2024-11-03")) expect(items).toHaveLength(8) }) it("finds an item by year", () => { const { collection } = setup() - const items = $(collection.findBy("year", 2024)) + const items = E.runSync(collection.findBy("year", 2024)) expect(items).toHaveLength(14) }) }) @@ -102,16 +102,16 @@ describe("Collection", () => { content: "new", date: LocalDate.parse("2024-11-04"), }) - const result = $(collection.add(newDone)) + const result = E.runSync(collection.add(newDone)) expect(result).toBe(newDone) - expect($(collection.all())).toHaveLength(15) - expect($(collection.findBy("contactId", alice.id))).toHaveLength(8) - expect($(collection.findBy("week", "2024-11-03"))).toHaveLength(9) + expect(E.runSync(collection.all())).toHaveLength(15) + expect(E.runSync(collection.findBy("contactId", alice.id))).toHaveLength(8) + expect(E.runSync(collection.findBy("week", "2024-11-03"))).toHaveLength(9) }) it("adds multiple items to the collection", () => { const { collection } = setup() - expect($(collection.all())).toHaveLength(14) + expect(E.runSync(collection.all())).toHaveLength(14) // generate a bunch of dones const dones = generateDones({ @@ -121,39 +121,39 @@ describe("Collection", () => { enthusiasm: 0.1, contacts, }) - const result = $(collection.add(dones)) + const result = E.runSync(collection.add(dones)) expect(result).toBe(dones) - expect($(collection.all())).toHaveLength(224) + expect(E.runSync(collection.all())).toHaveLength(224) }) }) describe("update", () => { it("updates a string value", () => { const { collection } = setup() - const dones = $(collection.all()) + const dones = E.runSync(collection.all()) const item = dones[0] - $(collection.update({ ...item, content: "updated" })) - const updated = $(collection.find(item.id)) + E.runSync(collection.update({ ...item, content: "updated" })) + const updated = E.runSync(collection.find(item.id)) expect(updated.content).toBe("updated") }) it("updates a date value", () => { const { collection } = setup() - const dones = $(collection.all()) + const dones = E.runSync(collection.all()) const item = dones[0] - $(collection.update({ ...item, date: LocalDate.parse("2024-11-05") })) + E.runSync(collection.update({ ...item, date: LocalDate.parse("2024-11-05") })) - const updated = $(collection.find(item.id)) + const updated = E.runSync(collection.find(item.id)) expect(updated.date.toString()).toBe("2024-11-05") }) it("updates an array value", () => { const { collection } = setup() - const dones = $(collection.all()) + const dones = E.runSync(collection.all()) const item = dones[0] - $(collection.update({ ...item, likes: [bob] })) + E.runSync(collection.update({ ...item, likes: [bob] })) - const updated = $(collection.find(item.id)) + const updated = E.runSync(collection.find(item.id)) expect(updated.likes).toEqual([bob]) }) }) @@ -161,19 +161,19 @@ describe("Collection", () => { describe("destroy", () => { it("removes an item from the collection", () => { const { collection } = setup() - const dones = $(collection.all()) + const dones = E.runSync(collection.all()) const item = dones[0] - $(collection.destroy(item.id)) + E.runSync(collection.destroy(item.id)) - expect($(collection.all())).toHaveLength(13) + expect(E.runSync(collection.all())).toHaveLength(13) }) }) describe("destroyAll", () => { it("removes all items from the collection", () => { const { collection } = setup() - $(collection.destroyAll()) - expect($(collection.all())).toHaveLength(0) + E.runSync(collection.destroyAll()) + expect(E.runSync(collection.all())).toHaveLength(0) }) }) }) diff --git a/app/context/Database/test/Indexes.test.ts b/app/context/Database/test/Indexes.test.ts index 53b151be..f34dff08 100644 --- a/app/context/Database/test/Indexes.test.ts +++ b/app/context/Database/test/Indexes.test.ts @@ -1,5 +1,5 @@ import { LocalDate } from "@js-joda/core" -import { $, E, Either } from "lib/Effect" +import { E, Either } from "lib/Effect" import { getSunday } from "lib/getSunday" import { createId } from "schema/Cuid" import { assert, describe, expect, it } from "vitest" @@ -20,7 +20,7 @@ describe("UniqueIndex", () => { describe("constructor", () => { it("builds an index", () => { const { index } = setup() - expect($(index.keys())).toEqual(["1", "2"]) + expect(E.runSync(index.keys())).toEqual(["1", "2"]) }) it("fails if there are duplicate keys", () => { @@ -32,7 +32,7 @@ describe("UniqueIndex", () => { expect(() => { const index = new UniqueIndex(items, "name") - const _ = $(index.all()) // index is built on demand, so this won't fail until we access it + const _ = E.runSync(index.all()) // index is built on demand, so this won't fail until we access it }).toThrowError(`Duplicate key 'alice' found while building index`) }) }) @@ -40,34 +40,34 @@ describe("UniqueIndex", () => { describe("keys", () => { it("returns all keys in the index", () => { const { index } = setup() - expect($(index.keys())).toEqual(["1", "2"]) + expect(E.runSync(index.keys())).toEqual(["1", "2"]) }) }) describe("find", () => { it("finds an item by key", () => { const { index } = setup() - const result = $(index.find("1")) + const result = E.runSync(index.find("1")) expect(result).toEqual({ id: "1", name: "Alice" }) }) it("is case insensitive", () => { const { items } = setup() const index = new UniqueIndex(items, "name") - const result = $(index.find("alice")) + const result = E.runSync(index.find("alice")) expect(result).toEqual({ id: "1", name: "Alice" }) }) it("fails if the key is not found", () => { const { index } = setup() - const result = index.find("3").pipe(E.either, $) + const result = index.find("3").pipe(E.either, E.runSync) expect(Either.isLeft(result)).toBe(true) }) it("supports a simple key accessor", () => { const { items } = setup() const index = new UniqueIndex(items, item => item.id) - expect($(index.find("1"))).toEqual({ id: "1", name: "Alice" }) + expect(E.runSync(index.find("1"))).toEqual({ id: "1", name: "Alice" }) }) }) @@ -76,12 +76,12 @@ describe("UniqueIndex", () => { const { index } = setup() const newItem = { id: "3", name: "Charlie" } index.add(newItem) - expect($(index.find("3"))).toEqual(newItem) + expect(E.runSync(index.find("3"))).toEqual(newItem) }) it("fails if the key already exists", () => { const { index } = setup() - const result = index.add({ id: "1", name: "Bob" }).pipe(E.either, $) + const result = index.add({ id: "1", name: "Bob" }).pipe(E.either, E.runSync) expect(Either.isLeft(result)).toBe(true) }) }) @@ -89,13 +89,13 @@ describe("UniqueIndex", () => { describe("update", () => { it("updates an item", () => { const { index } = setup() - $(index.update({ id: "1", name: "Agnes" })) - expect($(index.find("1"))).toEqual({ id: "1", name: "Agnes" }) + E.runSync(index.update({ id: "1", name: "Agnes" })) + expect(E.runSync(index.find("1"))).toEqual({ id: "1", name: "Agnes" }) }) it("fails if the key does not exist", () => { const { index } = setup() - const result = index.update({ id: "3", name: "Charlie" }).pipe(E.either, $) + const result = index.update({ id: "3", name: "Charlie" }).pipe(E.either, E.runSync) expect(Either.isLeft(result)).toBe(true) }) }) @@ -103,13 +103,13 @@ describe("UniqueIndex", () => { describe("destroy", () => { it("removes an item", () => { const { index } = setup() - $(index.destroy({ id: "1", name: "Alice" })) - expect($(index.all())).toHaveLength(1) + E.runSync(index.destroy({ id: "1", name: "Alice" })) + expect(E.runSync(index.all())).toHaveLength(1) }) it("fails if the key does not exist", () => { const { index } = setup() - const result = index.destroy({ id: "3", name: "Charlie" }).pipe(E.either, $) + const result = index.destroy({ id: "3", name: "Charlie" }).pipe(E.either, E.runSync) expect(Either.isLeft(result)).toBe(true) }) }) @@ -117,8 +117,8 @@ describe("UniqueIndex", () => { describe("destroyAll", () => { it("removes all items", () => { const { index } = setup() - $(index.destroyAll()) - expect($(index.all())).toEqual([]) + E.runSync(index.destroyAll()) + expect(E.runSync(index.all())).toEqual([]) }) }) }) @@ -164,61 +164,61 @@ describe("NonUniqueIndex", () => { describe("constructor", () => { it("builds an index", () => { const { index } = setup() - expect($(index.all())).toHaveLength(14) + expect(E.runSync(index.all())).toHaveLength(14) }) }) describe("keys", () => { it("returns all keys in the index", () => { const { index } = setup() - expect($(index.keys())).toEqual(["alice", "bob"]) + expect(E.runSync(index.keys())).toEqual(["alice", "bob"]) }) }) describe("find", () => { it("groups with the same key", () => { const { index } = setup() - expect($(index.find("Alice"))).toHaveLength(7) + expect(E.runSync(index.find("Alice"))).toHaveLength(7) }) it("returns an empty array if the key is not found", () => { const { index } = setup() - expect($(index.find("Charlie"))).toEqual([]) + expect(E.runSync(index.find("Charlie"))).toEqual([]) }) it("accepts a function to return a key", () => { const { dones } = setup() const index = new NonUniqueIndex(dones, item => item.userName) - expect($(index.find("Alice"))).toHaveLength(7) + expect(E.runSync(index.find("Alice"))).toHaveLength(7) }) it("indexes dones by week", () => { const { dones } = setup() const byWeek = (done: Done) => getSunday(done.date).toString() const index = new NonUniqueIndex(dones, byWeek) - expect($(index.keys())).toEqual(["2024-11-03", "2024-11-10"]) + expect(E.runSync(index.keys())).toEqual(["2024-11-03", "2024-11-10"]) }) }) describe("add", () => { it("adds an item to the index", () => { const { index } = setup() - $(index.add(new Done("Charlie", "asdf", "2024-11-12"))) - expect($(index.find("Charlie"))).toHaveLength(1) + E.runSync(index.add(new Done("Charlie", "asdf", "2024-11-12"))) + expect(E.runSync(index.find("Charlie"))).toHaveLength(1) }) it("adds an item with an existing key to the index", () => { const { index } = setup() - $(index.add(new Done("Alice", "zzz", "2024-11-13"))) - expect($(index.find("Alice"))).toHaveLength(8) + E.runSync(index.add(new Done("Alice", "zzz", "2024-11-13"))) + expect(E.runSync(index.find("Alice"))).toHaveLength(8) }) }) describe("update", () => { it("updates an item in the index", () => { const { index } = setup() - const firstDone = () => $(index.all())[0] - $(index.update({ ...firstDone(), content: "zzz" })) + const firstDone = () => E.runSync(index.all())[0] + E.runSync(index.update({ ...firstDone(), content: "zzz" })) expect(firstDone().content).toBe("zzz") }) @@ -226,7 +226,7 @@ describe("NonUniqueIndex", () => { const { index } = setup() const done = new Done("Charlie", "asdf", "2024-11-12") - const result = index.update(done).pipe(E.either, $) + const result = index.update(done).pipe(E.either, E.runSync) assert(Either.isLeft(result)) expect(result.left.message).toBe(`An item with userName "charlie" wasn't found`) }) @@ -236,16 +236,16 @@ describe("NonUniqueIndex", () => { it("removes an item from the index", () => { const { index } = setup() - const done = $(index.all())[0] - $(index.destroy(done)) - expect($(index.all())).toHaveLength(13) + const done = E.runSync(index.all())[0] + E.runSync(index.destroy(done)) + expect(E.runSync(index.all())).toHaveLength(13) }) it("fails if the item is not found", () => { const { index } = setup() const done = new Done("Charlie", "asdf", "2024-11-12") - const result = index.destroy(done).pipe(E.either, $) + const result = index.destroy(done).pipe(E.either, E.runSync) assert(Either.isLeft(result)) expect(result.left.message).toBe(`An item with userName "charlie" wasn't found`) }) @@ -254,8 +254,8 @@ describe("NonUniqueIndex", () => { describe("destroyAll", () => { it("removes all items from the index", () => { const { index } = setup() - $(index.destroyAll()) - expect($(index.all())).toHaveLength(0) + E.runSync(index.destroyAll()) + expect(E.runSync(index.all())).toHaveLength(0) }) }) }) diff --git a/app/hooks/useInvitation.tsx b/app/hooks/useInvitation.tsx index e948037c..7527bf3c 100644 --- a/app/hooks/useInvitation.tsx +++ b/app/hooks/useInvitation.tsx @@ -1,6 +1,6 @@ import type { Invitation } from "@localfirst/auth" import { useTeam } from "hooks/useTeam" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { useEffect, useState } from "react" import { type ExtendedContact } from "schema/Contact" import { useDatabase } from "./useDatabase" @@ -23,7 +23,7 @@ export function useInvitation(contact: ExtendedContact) { // update the contact record const updated = { ...contact, invitationId: undefined } - $(contacts.update(updated)) + E.runSync(contacts.update(updated)) } } diff --git a/app/hooks/useMemberInvitationGenerator.tsx b/app/hooks/useMemberInvitationGenerator.tsx index 5803ef8d..9414e316 100644 --- a/app/hooks/useMemberInvitationGenerator.tsx +++ b/app/hooks/useMemberInvitationGenerator.tsx @@ -6,7 +6,7 @@ import { getShareId } from "@localfirst/auth-provider-automerge-repo" import { randomKey } from "@localfirst/crypto" import { useTeam } from "hooks/useTeam" import { HOUR } from "lib/constants" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { useEffect, useState } from "react" import { type ExtendedContact } from "schema/Contact" import { useDatabase } from "./useDatabase" @@ -28,7 +28,7 @@ export function useMemberInvitationGenerator(contact?: ExtendedContact) { const { id } = team.inviteMember({ seed, expiration, maxUses }) // Record the invitation on the contact's document - $(contacts.update({ ...contact, invitationId: id })) + E.runSync(contacts.update({ ...contact, invitationId: id })) // The "invitation code" that we give the member is the shareId + the invitation seed const shareId = getShareId(team) diff --git a/app/lib/Effect.ts b/app/lib/Effect.ts index f0b8f7d9..3dce82a3 100644 --- a/app/lib/Effect.ts +++ b/app/lib/Effect.ts @@ -1,7 +1,5 @@ // Convenience wrapper of the Effect library with E, S, $ aliases -import { Effect } from "effect" - export { Schema as S, ParseResult } from "@effect/schema" export { Console, @@ -15,5 +13,3 @@ export { Types, Array, } from "effect" - -export const $ = Effect.runSync diff --git a/app/lib/runTestCases.ts b/app/lib/runTestCases.ts index 12bf8205..110bc06b 100644 --- a/app/lib/runTestCases.ts +++ b/app/lib/runTestCases.ts @@ -1,4 +1,4 @@ -import { $, E, Either, pipe } from "lib/Effect" +import { E, Either, pipe } from "lib/Effect" import { test as _test, assert, expect } from "vitest" export const runTestCases = < @@ -27,7 +27,7 @@ export const runTestCases = < input, // decoder, E.either, - $, + E.runSync, ) const errorPadding = Math.max(...testCases.filter(tc => tc.error).map(tc => label(tc).length)) diff --git a/app/lib/test/csvToDoneEntries.test.ts b/app/lib/test/csvToDoneEntries.test.ts index 1855da9c..7848d8fc 100644 --- a/app/lib/test/csvToDoneEntries.test.ts +++ b/app/lib/test/csvToDoneEntries.test.ts @@ -1,7 +1,7 @@ import { clients } from "data/clients" import { contacts } from "data/contacts" import { projects } from "data/projects" -import { $, E, pipe } from "lib/Effect" +import { E, pipe } from "lib/Effect" import { type BaseTestCase } from "lib/runTestCases" import { ClientCollection, ProvidedClients } from "schema/ClientCollection" import { ContactCollection, ProvidedContacts } from "schema/ContactCollection" @@ -73,7 +73,7 @@ const decode = (csv: string) => E.provideService(ProvidedContacts, TestContacts), E.provideService(ProvidedProjects, TestProjects), E.provideService(ProvidedClients, TestClients), - $, + E.runSync, ) for (const testCase of testCases) { diff --git a/app/lib/test/csvToTimeEntries.test.ts b/app/lib/test/csvToTimeEntries.test.ts index d6c93bd3..d60fe6f5 100644 --- a/app/lib/test/csvToTimeEntries.test.ts +++ b/app/lib/test/csvToTimeEntries.test.ts @@ -2,7 +2,7 @@ import { clients } from "data/clients" import { contacts } from "data/contacts" import actualHoursCsv from "data/csv/actual-hours.csv?raw" import { projects } from "data/projects" -import { $, E, pipe } from "lib/Effect" +import { E, pipe } from "lib/Effect" import { type BaseTestCase } from "lib/runTestCases" import { ClientCollection, ProvidedClients } from "schema/ClientCollection" import { ContactCollection, ProvidedContacts } from "schema/ContactCollection" @@ -87,7 +87,7 @@ function decode(csv: string) { E.provideService(ProvidedContacts, TestContacts), E.provideService(ProvidedProjects, TestProjects), E.provideService(ProvidedClients, TestClients), - $, + E.runSync, ) } diff --git a/app/routes/_private+/devtools+/danger+/_danger.tsx b/app/routes/_private+/devtools+/danger+/_danger.tsx index 63030bcd..789f1caa 100644 --- a/app/routes/_private+/devtools+/danger+/_danger.tsx +++ b/app/routes/_private+/devtools+/danger+/_danger.tsx @@ -1,6 +1,6 @@ import { Alert, AlertDescription } from "@ui/alert" import { useDatabase } from "hooks/useDatabase" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { DoneEntry } from "schema/DoneEntry" import { DoneEntryGenerator } from "./ui/DoneEntryGenerator" import { DoneEntryImporter } from "./ui/DoneEntryImporter" @@ -27,9 +27,9 @@ export default function DangerPage() { content: (
$(doneEntries.add(done))} - destroyAll={() => $(doneEntries.destroyAll())} + contacts={E.runSync(contacts.all())} + add={done => E.runSync(doneEntries.add(done))} + destroyAll={() => E.runSync(doneEntries.destroyAll())} />
), @@ -38,9 +38,9 @@ export default function DangerPage() { heading: "Import dones data", content: ( $(doneEntries.add(new DoneEntry(done)))} - destroyAll={() => $(doneEntries.destroyAll())} + contacts={E.runSync(contacts.all())} + add={done => E.runSync(doneEntries.add(new DoneEntry(done)))} + destroyAll={() => E.runSync(doneEntries.destroyAll())} /> ), }, @@ -49,11 +49,11 @@ export default function DangerPage() { content: (
$(timeEntries.add(t))} - destroyAll={() => $(timeEntries.destroyAll())} + contacts={E.runSync(contacts.all())} + clients={E.runSync(clients.all())} + projects={E.runSync(projects.all())} + add={t => E.runSync(timeEntries.add(t))} + destroyAll={() => E.runSync(timeEntries.destroyAll())} />
), @@ -64,11 +64,11 @@ export default function DangerPage() {
$(timeEntries.destroyAll())} - add={timeEntry => $(timeEntries.add(timeEntry))} - contacts={$(contacts.all())} - clients={$(clients.all())} - projects={$(projects.all())} + destroyAll={() => E.runSync(timeEntries.destroyAll())} + add={timeEntry => E.runSync(timeEntries.add(timeEntry))} + contacts={E.runSync(contacts.all())} + clients={E.runSync(clients.all())} + projects={E.runSync(projects.all())} />
), diff --git a/app/routes/_private+/devtools+/danger+/ui/DoneEntryImporter.tsx b/app/routes/_private+/devtools+/danger+/ui/DoneEntryImporter.tsx index 93092ab1..4cc78596 100644 --- a/app/routes/_private+/devtools+/danger+/ui/DoneEntryImporter.tsx +++ b/app/routes/_private+/devtools+/danger+/ui/DoneEntryImporter.tsx @@ -1,7 +1,7 @@ import { Button } from "@ui/button" import { NO_OP } from "lib/constants" import { csvToDoneEntries } from "lib/csvToDoneEntries" -import { $, E, pipe } from "lib/Effect" +import { E, pipe } from "lib/Effect" import { useState } from "react" import { type Contact } from "schema/Contact" import { ContactCollection, ProvidedContacts } from "schema/ContactCollection" @@ -18,7 +18,7 @@ export const DoneEntryImporter = ({ add = NO_OP, destroyAll = NO_OP, contacts = csv, csvToDoneEntries, E.provideService(ProvidedContacts, new ContactCollection(contacts)), - $, + E.runSync, ) const onImportDataChange = (event: React.ChangeEvent) => { diff --git a/app/routes/_private+/devtools+/danger+/ui/TimeEntryImporter.tsx b/app/routes/_private+/devtools+/danger+/ui/TimeEntryImporter.tsx index eee749b1..dc772d54 100644 --- a/app/routes/_private+/devtools+/danger+/ui/TimeEntryImporter.tsx +++ b/app/routes/_private+/devtools+/danger+/ui/TimeEntryImporter.tsx @@ -1,7 +1,7 @@ import { Button } from "@ui/button" import { NO_OP } from "lib/constants" import { csvToTimeEntries } from "lib/csvToTimeEntries" -import { $, E, pipe } from "lib/Effect" +import { E, pipe } from "lib/Effect" import { useState } from "react" import { type Client } from "schema/Client" import { ClientCollection, ProvidedClients } from "schema/ClientCollection" @@ -31,7 +31,7 @@ export const TimeEntryImporter = ({ E.provideService(ProvidedContacts, new ContactCollection(contacts)), E.provideService(ProvidedProjects, new ProjectCollection(projects)), E.provideService(ProvidedClients, new ClientCollection(clients)), - $, + E.runSync, ) const onImportDataChange = (event: React.ChangeEvent) => { diff --git a/app/routes/_private+/dones+/$date.tsx b/app/routes/_private+/dones+/$date.tsx index 6daa3fda..c660abad 100644 --- a/app/routes/_private+/dones+/$date.tsx +++ b/app/routes/_private+/dones+/$date.tsx @@ -1,7 +1,7 @@ import { useDatabase } from "hooks/useDatabase" import { useSelectedWeek } from "hooks/useSelectedWeek" import { useTeam } from "hooks/useTeam" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { PageLayout } from "ui/layouts/PageLayout" import { TeamDones } from "ui/TeamDones" import { WeekNav } from "ui/WeekNav" @@ -11,7 +11,7 @@ export default function Dones$DatePage() { const { start } = useSelectedWeek() const { self, contacts } = useTeam() - const dones = $(doneEntries.findBy("week", start)) + const dones = E.runSync(doneEntries.findBy("week", start)) return ( $(doneEntries.update({ id, likes }))} + updateLikes={(id, likes) => E.runSync(doneEntries.update({ id, likes }))} /> diff --git a/app/routes/_private+/hours+/$year.tsx b/app/routes/_private+/hours+/$year.tsx index 8e6334b2..af109de2 100644 --- a/app/routes/_private+/hours+/$year.tsx +++ b/app/routes/_private+/hours+/$year.tsx @@ -2,7 +2,7 @@ import { useDatabase } from "hooks/useDatabase" import { useRedirect } from "hooks/useRedirect" import { useSelectedYear } from "hooks/useSelectedYear" import { useTeam } from "hooks/useTeam" -import { $, Array as A } from "lib/Effect" +import { Array as Arr, E } from "lib/Effect" import { getCurrentYear } from "lib/getCurrentYear" import { HoursReport } from "ui/HoursReport" import { PageLayout } from "ui/layouts/PageLayout" @@ -16,7 +16,7 @@ export default function Hours$YearPage() { useRedirect({ from: "/hours", to: `/hours/${currentYear}`, condition: year > currentYear }) - const years = A.dedupe($(timeEntries.all()).map(entry => entry.date.year())) + const years = Arr.dedupe(E.runSync(timeEntries.all()).map(entry => entry.date.year())) const minYear = Math.min(...years) const maxYear = Math.max(...years) diff --git a/app/routes/_private+/team+/members+/invite.tsx b/app/routes/_private+/team+/members+/invite.tsx index ed369538..19b8c5ca 100644 --- a/app/routes/_private+/team+/members+/invite.tsx +++ b/app/routes/_private+/team+/members+/invite.tsx @@ -1,7 +1,7 @@ import { useDatabase } from "hooks/useDatabase" import { useMemberInvitationGenerator } from "hooks/useMemberInvitationGenerator" import { useTeam } from "hooks/useTeam" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { useLocation, useNavigate } from "react-router-dom" import { type ContactId } from "schema/Contact" import { InviteMemberDialog } from "ui/InviteMemberDialog" @@ -14,7 +14,7 @@ export default function MembersInvitePage() { const navigate = useNavigate() // look up the contact information for the user we're inviting - const contact = $(contacts.find(userId)) + const contact = E.runSync(contacts.find(userId)) // generate an invitation code for the contact const invitationCode = useMemberInvitationGenerator(contact) diff --git a/app/routes/_private+/team+/members+/remove.tsx b/app/routes/_private+/team+/members+/remove.tsx index 99e94e1a..f52ae2a7 100644 --- a/app/routes/_private+/team+/members+/remove.tsx +++ b/app/routes/_private+/team+/members+/remove.tsx @@ -1,6 +1,6 @@ import { useDatabase } from "hooks/useDatabase" import { useTeam } from "hooks/useTeam" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { useLocation, useNavigate } from "react-router-dom" import type { ContactId } from "schema/Contact" import { RemoveMemberDialog } from "ui/RemoveMemberDialog" @@ -17,7 +17,7 @@ export default function RemovePage() { // Only admins can remove members if (!self?.isAdmin) return null - const contact = $(contacts.find(userId)) + const contact = E.runSync(contacts.find(userId)) return ( ("Contact")({ extendContact, E.provideService(ProvidedTeam, team), E.provideService(ProvidedUser, user), - $, + E.runSync, ) } @@ -86,6 +86,6 @@ export class ExtendedContact extends Contact.extend("ExtendedCo extendContact, E.provideService(ProvidedTeam, team), E.provideService(ProvidedUser, user), - $, + E.runSync, ) } diff --git a/app/schema/DoneEntry.ts b/app/schema/DoneEntry.ts index 44be23f6..734a2b4f 100644 --- a/app/schema/DoneEntry.ts +++ b/app/schema/DoneEntry.ts @@ -1,4 +1,4 @@ -import { $, E, pipe, S } from "lib/Effect" +import { E, pipe, S } from "lib/Effect" import { stripUndefined } from "lib/stripUndefined" import { ContactId } from "schema/Contact" import { Cuid } from "schema/Cuid" @@ -24,7 +24,7 @@ export class DoneEntry extends S.Class("DoneEntry")({ encoded, // S.decode(DoneEntry), E.provideService(ProvidedContacts, contacts), - $, + E.runSync, ) static encode = (decoded: DoneEntry) => @@ -32,7 +32,7 @@ export class DoneEntry extends S.Class("DoneEntry")({ decoded, // S.encode(DoneEntry), E.provideService(ProvidedContacts, new ContactCollection()), - $, + E.runSync, stripUndefined, ) } diff --git a/app/schema/Root.ts b/app/schema/Root.ts index 0969c8fe..c44ea248 100644 --- a/app/schema/Root.ts +++ b/app/schema/Root.ts @@ -1,5 +1,5 @@ import type * as Auth from "@localfirst/auth" -import { $, E, pipe, S } from "lib/Effect" +import { E, pipe, S } from "lib/Effect" import { Client, ClientId } from "./Client" import { ClientCollection, ProvidedClients } from "./ClientCollection" import { @@ -39,7 +39,7 @@ export class Root extends S.Class("Root")({ E.provideService(ProvidedProjects, new ProjectCollection(projects)), E.provideService(ProvidedClients, new ClientCollection(clients)), E.provideService(ProvidedContacts, new ContactCollection(contacts)), - $, + E.runSync, ) } @@ -52,7 +52,7 @@ export class Root extends S.Class("Root")({ }).pipe( E.provideService(ProvidedUser, user), // E.provideService(ProvidedTeam, team), - $, + E.runSync, ) } } diff --git a/app/schema/TimeEntry.ts b/app/schema/TimeEntry.ts index 03137537..6400f87d 100644 --- a/app/schema/TimeEntry.ts +++ b/app/schema/TimeEntry.ts @@ -1,4 +1,4 @@ -import { $, E, pipe, S } from "lib/Effect" +import { E, pipe, S } from "lib/Effect" import { stripUndefined } from "lib/stripUndefined" import { ClientCollection, ProvidedClients } from "./ClientCollection" import { ClientFromId } from "./ClientFromId" @@ -41,7 +41,7 @@ export class TimeEntry extends S.Class("TimeEntry")({ S.decode(TimeEntry), E.provideService(ProvidedProjects, projects), E.provideService(ProvidedClients, clients), - $, + E.runSync, ) static encode = (decoded: TimeEntry) => @@ -53,7 +53,7 @@ export class TimeEntry extends S.Class("TimeEntry")({ E.provideService(ProvidedProjects, new ProjectCollection([])), E.provideService(ProvidedClients, new ClientCollection([])), - $, + E.runSync, stripUndefined, ) @@ -83,7 +83,7 @@ export class TimeEntry extends S.Class("TimeEntry")({ multilineInput, s => s.split("\n"), E.partition(parse), // try to parse each line - $, + E.runSync, ) } } diff --git a/app/schema/test/TimeEntry.test.ts b/app/schema/test/TimeEntry.test.ts index d9856802..9e11ba26 100644 --- a/app/schema/test/TimeEntry.test.ts +++ b/app/schema/test/TimeEntry.test.ts @@ -2,7 +2,7 @@ import { LocalDate } from "@js-joda/core" import { clients } from "data/clients" import { projects } from "data/projects" -import { $, E, pipe, S } from "lib/Effect" +import { E, pipe, S } from "lib/Effect" import { ClientCollection, ProvidedClients } from "schema/ClientCollection" import { parseTimeEntry } from "schema/lib/parseTimeEntry" import { ProjectCollection, ProvidedProjects } from "schema/ProjectCollection" @@ -21,7 +21,7 @@ describe("TimeEntry", () => { parseTimeEntry, E.provideService(ProvidedProjects, TestProjects), E.provideService(ProvidedClients, TestClients), - $, + E.runSync, ) const encode = (decoded: TimeEntry) => @@ -30,7 +30,7 @@ describe("TimeEntry", () => { S.encode(TimeEntry), E.provideService(ProvidedProjects, TestProjects), E.provideService(ProvidedClients, TestClients), - $, + E.runSync, ) const decode = (encoded: TimeEntryEncoded) => @@ -39,7 +39,7 @@ describe("TimeEntry", () => { S.decode(TimeEntry), E.provideService(ProvidedProjects, TestProjects), E.provideService(ProvidedClients, TestClients), - $, + E.runSync, ) it("parses a TimeEntry", () => { diff --git a/app/ui/AutocompleteMenu.tsx b/app/ui/AutocompleteMenu.tsx index 1868aa04..7fe83491 100644 --- a/app/ui/AutocompleteMenu.tsx +++ b/app/ui/AutocompleteMenu.tsx @@ -1,7 +1,7 @@ import type { Collection } from "context/Database/Collection" import type { CollectionItem, ItemOf, StringKeyOf } from "context/Database/types" import { cx } from "lib/cx" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { Keys } from "lib/keys" import { useState } from "react" import { useHotkeys } from "react-hotkeys-hook" @@ -104,7 +104,7 @@ export const getAutocompleteItems = < const { collection, property } = mode - return $(collection.all()) + return E.runSync(collection.all()) .map(item => String(item[property])) .filter(value => value.toLowerCase().includes(query.toLowerCase())) .sort((a, b) => { diff --git a/app/ui/DailyDones.tsx b/app/ui/DailyDones.tsx index 8fc110d5..40490eb1 100644 --- a/app/ui/DailyDones.tsx +++ b/app/ui/DailyDones.tsx @@ -1,6 +1,6 @@ import { type LocalDate } from "@js-joda/core" import { cx } from "lib/cx" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { useState } from "react" import { type Contact } from "schema/Contact" import { DoneEntry } from "schema/DoneEntry" @@ -12,7 +12,7 @@ import { DoneInput } from "ui/DoneInput" export const DailyDones = ({ date, doneEntries, self }: Props) => { const [focus, setFocus] = useState(-1) // nothing focused by default - const dones = $(doneEntries.findBy("date", date)) // for this day + const dones = E.runSync(doneEntries.findBy("date", date)) // for this day .filter(({ contactId }) => contactId === self.id) // for this contact const focusNext = () => setFocus((f: number) => Math.min(f + 1, dones.length + 1)) @@ -27,8 +27,8 @@ export const DailyDones = ({ date, doneEntries, self }: Props) => { $(doneEntries.update({ id: done.id, content }))} - onDestroy={() => $(doneEntries.destroy(done.id))} + onUpdate={content => E.runSync(doneEntries.update({ id: done.id, content }))} + onDestroy={() => E.runSync(doneEntries.destroy(done.id))} isFocused={focus === index} onFocus={setFocus} onFocusNext={focusNext} @@ -54,7 +54,7 @@ export const DailyDones = ({ date, doneEntries, self }: Props) => { onFocusPrev={focusPrev} onDestroy={() => {}} onChange={content => { - $(doneEntries.add(new DoneEntry({ date, contactId: self.id, content }))) + E.runSync(doneEntries.add(new DoneEntry({ date, contactId: self.id, content }))) setFocus(dones.length + 1) }} /> diff --git a/app/ui/DailyTimeEntries.tsx b/app/ui/DailyTimeEntries.tsx index 1577308f..45a904b7 100644 --- a/app/ui/DailyTimeEntries.tsx +++ b/app/ui/DailyTimeEntries.tsx @@ -1,6 +1,6 @@ import type { LocalDate } from "@js-joda/core" import { cx } from "lib/cx" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { useState } from "react" import { type ClientCollection } from "schema/ClientCollection" import type { Contact } from "schema/Contact" @@ -21,7 +21,7 @@ export const DailyTimeEntries = ({ }: Props) => { const [focus, setFocus] = useState(-1) // nothing focused by default - const entries = $(timeEntries.findBy("date", date)) // for this day + const entries = E.runSync(timeEntries.findBy("date", date)) // for this day .filter(({ contactId }) => contactId === self.id) // for this contact const onFocusNext = () => setFocus((f: number) => f + 1) @@ -59,13 +59,13 @@ export const DailyTimeEntries = ({ {...{ index, date, projects, clients, self, onFocusNext, onFocusPrev, onDiscard }} isFocused={focus === index} onFocus={setFocus} - onDestroy={() => $(timeEntries.destroy(timeEntry.id))} - onCommit={e => $(timeEntries.update({ ...e, id: timeEntry.id }))} + onDestroy={() => E.runSync(timeEntries.destroy(timeEntry.id))} + onCommit={e => E.runSync(timeEntries.update({ ...e, id: timeEntry.id }))} /> :
- $(timeEntries.destroy(timeEntry.id))} /> + E.runSync(timeEntries.destroy(timeEntry.id))} />
} @@ -81,7 +81,7 @@ export const DailyTimeEntries = ({ {...{ date, projects, clients, self, onFocusNext, onFocusPrev, onDiscard }} isFocused={focus === entries.length} onFocus={setFocus} - onCommit={e => $(timeEntries.add(e))} + onCommit={e => E.runSync(timeEntries.add(e))} /> diff --git a/app/ui/HoursReport.tsx b/app/ui/HoursReport.tsx index 592461c3..56102385 100644 --- a/app/ui/HoursReport.tsx +++ b/app/ui/HoursReport.tsx @@ -1,24 +1,24 @@ import { LocalDate } from "@js-joda/core" +import { asPercentage } from "lib/asPercentage" import { cx } from "lib/cx" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { formatDate } from "lib/formatDate" import { formatDateRange } from "lib/formatDateRange" import { getSunday } from "lib/getSunday" +import { getSundaysForYear } from "lib/getSundaysForYear" import { isCompleteWeek } from "lib/isCompleteWeek" +import { plural } from "lib/plural" import { rankByScore } from "lib/rankByScore" import { sum } from "lib/sum" import { Fragment } from "react/jsx-runtime" import type { ContactId, ExtendedContact } from "schema/Contact" import type { ContactCollection } from "schema/ContactCollection" import type { TimeEntryCollection } from "schema/TimeEntryCollection" -import { asPercentage } from "lib/asPercentage" -import { getSundaysForYear } from "lib/getSundaysForYear" -import { plural } from "lib/plural" import { Avatar } from "./Avatar" import { CenteredLayout } from "./layouts/CenteredLayout" export const HoursReport = ({ year, contacts, timeEntries }: Props) => { - const entries = $(timeEntries.findBy("year", year)) + const entries = E.runSync(timeEntries.findBy("year", year)) if (entries.length === 0) { return ( @@ -33,7 +33,7 @@ export const HoursReport = ({ year, contacts, timeEntries }: Props) => { ) } - const allContacts = $(contacts.all()) + const allContacts = E.runSync(contacts.all()) /** Which contacts have any hours data at all? */ const reportingContacts = allContacts.filter(c => entries.some(e => e.contactId === c.id)) diff --git a/app/ui/MyWeek.tsx b/app/ui/MyWeek.tsx index 5ba28f64..4d5e0255 100644 --- a/app/ui/MyWeek.tsx +++ b/app/ui/MyWeek.tsx @@ -1,16 +1,16 @@ import { LocalDate } from "@js-joda/core" import { cx } from "lib/cx" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { DAY_OF_MONTH, DAY_OF_WEEK, formatDate } from "lib/formatDate" +import { formatDuration } from "lib/formatDuration" import { getDaysOfWeek } from "lib/getDaysOfWeek" import { isWeekend } from "lib/isWeekend" +import { sum } from "lib/sum" import type { ClientCollection } from "schema/ClientCollection" import type { Contact } from "schema/Contact" import type { DoneEntryCollection } from "schema/DoneEntryCollection" import type { ProjectCollection } from "schema/ProjectCollection" import type { TimeEntryCollection } from "schema/TimeEntryCollection" -import { formatDuration } from "lib/formatDuration" -import { sum } from "lib/sum" import { DailyDones } from "./DailyDones" import { DailyTimeEntries } from "./DailyTimeEntries" @@ -28,7 +28,7 @@ export const MyWeek = ({ }: Props) => { const days = getDaysOfWeek(start).filter(date => showWeekends || !isWeekend(date)) - const myTimeEntries = $(timeEntries.findBy("week", start)) // for the week being shown + const myTimeEntries = E.runSync(timeEntries.findBy("week", start)) // for the week being shown // only my entries .filter(({ contactId }) => contactId === self.id) diff --git a/app/ui/stories/HoursReport.stories.tsx b/app/ui/stories/HoursReport.stories.tsx index 99001148..a8e93474 100644 --- a/app/ui/stories/HoursReport.stories.tsx +++ b/app/ui/stories/HoursReport.stories.tsx @@ -3,7 +3,7 @@ import type { Decorator, Meta, StoryObj } from "@storybook/react" import { clients } from "data/clients" import { fakeExtendedContacts as contacts } from "data/contacts" import { projects } from "data/projects" -import { $ } from "lib/Effect" +import { E } from "lib/Effect" import { generateTimeEntries } from "lib/generateTimeEntries" import MockDate from "mockdate" import { ContactCollection } from "schema/ContactCollection" @@ -85,7 +85,7 @@ export const OneEntry = makeStory({ contactId: contacts[0].id, date: LocalDate.parse("2024-01-07"), duration: 60, - project: $(projectCollection.findBy("code", "Out"))[0], + project: E.runSync(projectCollection.findBy("code", "Out"))[0], input: "1h #out", }), ], From e20182c86becad7f202a2eec4afc2b8b3827e849 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 14:33:59 +0100 Subject: [PATCH 09/12] Collection: separate effectful & sync APIs --- app/context/Database/Collection.ts | 93 +++++++++++++++---- .../Database/test/Collection.benchmark.ts | 2 +- app/context/Database/test/Collection.test.ts | 54 +++++------ app/hooks/useInvitation.tsx | 2 +- app/hooks/useMemberInvitationGenerator.tsx | 2 +- app/lib/csvToDoneEntries.ts | 4 +- app/lib/csvToTimeEntries.ts | 5 +- .../_private+/devtools+/danger+/_danger.tsx | 32 +++---- app/routes/_private+/dones+/$date.tsx | 4 +- app/routes/_private+/hours+/$year.tsx | 2 +- .../_private+/team+/members+/invite.tsx | 2 +- .../_private+/team+/members+/remove.tsx | 4 +- app/schema/ClientFromId.ts | 2 +- app/schema/ContactFromId.ts | 2 +- app/schema/ProjectCollection.ts | 4 +- app/schema/ProjectFromId.ts | 2 +- app/schema/lib/parseClient.ts | 2 +- app/ui/AutocompleteMenu.tsx | 2 +- app/ui/DailyDones.tsx | 10 +- app/ui/DailyTimeEntries.tsx | 12 ++- app/ui/HoursReport.tsx | 4 +- app/ui/MyWeek.tsx | 2 +- app/ui/stories/HoursReport.stories.tsx | 2 +- 23 files changed, 157 insertions(+), 93 deletions(-) diff --git a/app/context/Database/Collection.ts b/app/context/Database/Collection.ts index c0c383a5..07e56fea 100644 --- a/app/context/Database/Collection.ts +++ b/app/context/Database/Collection.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import type { ChangeFn } from "@automerge/automerge" import { NO_OP } from "lib/constants" import { E, type Types } from "lib/Effect" @@ -79,13 +80,71 @@ export abstract class Collection< this.index = new UniqueIndex(items, "id" as StringKeyOf) } + // NON-EFFECTFUL (SYNC) API FOR USE BY APPLICATION + /** Returns all items in the collection. */ all() { - return this.index.all() + return E.runSync(this.effect_all()) } /** Finds an item by its ID. */ find(id: string) { + return E.runSync(this.effect_find(id)) + } + + /** + * Finds an item or items using an index. + * - If the index is unique (first overload), returns a single item, and throws if none is found. + * - If the index is non-unique (second overload), returns an array zero or more of items. + * + * @example `contactCollection.findBy("userName", "alice")` // returns one contact + * @example `contactCollection.findBy("company", "DevResults")` // returns an array of contacts + */ + findBy(...args: Parameters) { + return E.runSync(this.effect_findBy(...args)) + } + + /** Adds an item to the collection */ + add(arg: Item | Item[]) { + return E.runSync(this.effect_add(arg)) + } + + /** Updates an item given an object containing its ID and one or more updated properties. */ + update(item: { id: IdOf } & Partial) { + E.runSync(this.effect_update(item)) + } + + /** Removes an item from a collection, given its ID. */ + destroy(id: IdOf) { + E.runSync(this.effect_destroy(id)) + } + + /** Removes all items from the collection. */ + destroyAll() { + E.runSync(this.effect_destroyAll()) + } + + // EFFECTFUL API FOR USE WITHIN DATA LAYER + + public effect = { + all: this.effect_all.bind(this), + find: this.effect_find.bind(this), + findBy: this.effect_findBy.bind(this), + add: this.effect_add.bind(this), + update: this.effect_update.bind(this), + destroy: this.effect_destroy.bind(this), + destroyAll: this.effect_destroyAll.bind(this), + } + + // PRIVATE + + /** Returns all items in the collection. Returns an effect. */ + private effect_all() { + return this.index.all() + } + + /** Finds an item by its ID. Returns an effect. */ + private effect_find(id: string) { return this.index.find(id) } @@ -96,24 +155,26 @@ export abstract class Collection< * * @example `contactCollection.findBy("userName", "alice")` // returns one contact * @example `contactCollection.findBy("company", "DevResults")` // returns an array of contacts + * + * Returns an effect. */ - findBy>( + private effect_findBy>( indexName: IndexName, value: any, ): E.Effect | E.Effect - findBy>( + private effect_findBy>( indexName: IndexName, value: any, ): E.Effect - findBy>(indexName: IndexName, value: any) { + private effect_findBy>(indexName: IndexName, value: any) { const index = this.getIndex(indexName) return index.find(String(value)) } - /** Adds an item to the collection */ - add(arg: Item | Item[]) { + /** Adds an item to the collection. Returns an effect. */ + private effect_add(arg: Item | Item[]) { const items = Array.isArray(arg) ? arg : [arg] return E.gen(this, function* () { @@ -134,10 +195,10 @@ export abstract class Collection< }) } - /** Updates an item given an object containing its ID and one or more updated properties. */ - update(item: { id: IdOf } & Partial) { + /** Updates an item given an object containing its ID and one or more updated properties. Returns an effect. */ + private effect_update(item: { id: IdOf } & Partial) { return E.gen(this, function* () { - const prevValue = yield* this.find(item.id) + const prevValue = yield* this.effect_find(item.id) const newValue: Item = { ...prevValue, ...item } // update the item in all indexes @@ -155,10 +216,10 @@ export abstract class Collection< }) } - /** Removes an item from a collection, given its ID. */ - destroy(id: IdOf) { + /** Removes an item from a collection, given its ID. Returns an effect. */ + private effect_destroy(id: IdOf) { return E.gen(this, function* () { - const item = yield* this.find(id) + const item = yield* this.effect_find(id) // remove the item from all indexes for (const index of this.allIndexes) { @@ -173,8 +234,8 @@ export abstract class Collection< }) } - /** Removes all items from the collection. */ - destroyAll() { + /** Removes all items from the collection. Returns an effect. */ + private effect_destroyAll() { return E.gen(this, function* () { // remove all items from all indexes for (const index of this.allIndexes) { @@ -188,8 +249,6 @@ export abstract class Collection< }) } - // PRIVATE - /** Returns an array containing all indexes in the collection */ private get allIndexes() { return [ @@ -207,7 +266,7 @@ export abstract class Collection< // create the index on demand if (!(name in indexes)) { - const items = E.runSync(this.all()) + const items = E.runSync(this.effect_all()) const index = unique ? new UniqueIndex(items, key, name) // diff --git a/app/context/Database/test/Collection.benchmark.ts b/app/context/Database/test/Collection.benchmark.ts index b272a8ea..dd2b6682 100644 --- a/app/context/Database/test/Collection.benchmark.ts +++ b/app/context/Database/test/Collection.benchmark.ts @@ -17,7 +17,7 @@ const addEntries = (weekCount: number) => { }) const collection = new TimeEntryCollection([]) - E.runSync(collection.add(entries)) + E.runSync(collection.effect.add(entries)) } describe("collection.add()", () => { diff --git a/app/context/Database/test/Collection.test.ts b/app/context/Database/test/Collection.test.ts index 8f7258f5..27f76461 100644 --- a/app/context/Database/test/Collection.test.ts +++ b/app/context/Database/test/Collection.test.ts @@ -56,7 +56,7 @@ describe("Collection", () => { describe("constructor", () => { it("instantiates the collection", () => { const { collection } = setup() - expect(E.runSync(collection.all())).toHaveLength(14) + expect(E.runSync(collection.effect.all())).toHaveLength(14) }) }) @@ -64,32 +64,32 @@ describe("Collection", () => { it("finds an item by ID", () => { const { dones, collection } = setup() const { id } = dones[0] - const item = E.runSync(collection.find(id)) + const item = E.runSync(collection.effect.find(id)) expect(item).toEqual(dones[0]) }) it("fails if an ID is not found", () => { const { collection } = setup() - const result = collection.find("pizza").pipe(E.either, E.runSync) + const result = collection.effect.find("pizza").pipe(E.either, E.runSync) expect(Either.isLeft(result)).toBe(true) }) it("finds an item by a non-unique indexed property", () => { const { collection } = setup() - const items = E.runSync(collection.findBy("contactId", alice.id)) + const items = E.runSync(collection.effect.findBy("contactId", alice.id)) expect(items).toHaveLength(7) }) it("finds an item using an accessor function", () => { const { collection } = setup() - const items = E.runSync(collection.findBy("week", "2024-11-03")) + const items = E.runSync(collection.effect.findBy("week", "2024-11-03")) expect(items).toHaveLength(8) }) it("finds an item by year", () => { const { collection } = setup() - const items = E.runSync(collection.findBy("year", 2024)) + const items = E.runSync(collection.effect.findBy("year", 2024)) expect(items).toHaveLength(14) }) }) @@ -102,16 +102,16 @@ describe("Collection", () => { content: "new", date: LocalDate.parse("2024-11-04"), }) - const result = E.runSync(collection.add(newDone)) + const result = E.runSync(collection.effect.add(newDone)) expect(result).toBe(newDone) - expect(E.runSync(collection.all())).toHaveLength(15) - expect(E.runSync(collection.findBy("contactId", alice.id))).toHaveLength(8) - expect(E.runSync(collection.findBy("week", "2024-11-03"))).toHaveLength(9) + expect(E.runSync(collection.effect.all())).toHaveLength(15) + expect(E.runSync(collection.effect.findBy("contactId", alice.id))).toHaveLength(8) + expect(E.runSync(collection.effect.findBy("week", "2024-11-03"))).toHaveLength(9) }) it("adds multiple items to the collection", () => { const { collection } = setup() - expect(E.runSync(collection.all())).toHaveLength(14) + expect(E.runSync(collection.effect.all())).toHaveLength(14) // generate a bunch of dones const dones = generateDones({ @@ -121,39 +121,39 @@ describe("Collection", () => { enthusiasm: 0.1, contacts, }) - const result = E.runSync(collection.add(dones)) + const result = E.runSync(collection.effect.add(dones)) expect(result).toBe(dones) - expect(E.runSync(collection.all())).toHaveLength(224) + expect(E.runSync(collection.effect.all())).toHaveLength(224) }) }) describe("update", () => { it("updates a string value", () => { const { collection } = setup() - const dones = E.runSync(collection.all()) + const dones = E.runSync(collection.effect.all()) const item = dones[0] - E.runSync(collection.update({ ...item, content: "updated" })) - const updated = E.runSync(collection.find(item.id)) + E.runSync(collection.effect.update({ ...item, content: "updated" })) + const updated = E.runSync(collection.effect.find(item.id)) expect(updated.content).toBe("updated") }) it("updates a date value", () => { const { collection } = setup() - const dones = E.runSync(collection.all()) + const dones = E.runSync(collection.effect.all()) const item = dones[0] - E.runSync(collection.update({ ...item, date: LocalDate.parse("2024-11-05") })) + E.runSync(collection.effect.update({ ...item, date: LocalDate.parse("2024-11-05") })) - const updated = E.runSync(collection.find(item.id)) + const updated = E.runSync(collection.effect.find(item.id)) expect(updated.date.toString()).toBe("2024-11-05") }) it("updates an array value", () => { const { collection } = setup() - const dones = E.runSync(collection.all()) + const dones = E.runSync(collection.effect.all()) const item = dones[0] - E.runSync(collection.update({ ...item, likes: [bob] })) + E.runSync(collection.effect.update({ ...item, likes: [bob] })) - const updated = E.runSync(collection.find(item.id)) + const updated = E.runSync(collection.effect.find(item.id)) expect(updated.likes).toEqual([bob]) }) }) @@ -161,19 +161,19 @@ describe("Collection", () => { describe("destroy", () => { it("removes an item from the collection", () => { const { collection } = setup() - const dones = E.runSync(collection.all()) + const dones = E.runSync(collection.effect.all()) const item = dones[0] - E.runSync(collection.destroy(item.id)) + E.runSync(collection.effect.destroy(item.id)) - expect(E.runSync(collection.all())).toHaveLength(13) + expect(E.runSync(collection.effect.all())).toHaveLength(13) }) }) describe("destroyAll", () => { it("removes all items from the collection", () => { const { collection } = setup() - E.runSync(collection.destroyAll()) - expect(E.runSync(collection.all())).toHaveLength(0) + E.runSync(collection.effect.destroyAll()) + expect(E.runSync(collection.effect.all())).toHaveLength(0) }) }) }) diff --git a/app/hooks/useInvitation.tsx b/app/hooks/useInvitation.tsx index 7527bf3c..60f27b31 100644 --- a/app/hooks/useInvitation.tsx +++ b/app/hooks/useInvitation.tsx @@ -23,7 +23,7 @@ export function useInvitation(contact: ExtendedContact) { // update the contact record const updated = { ...contact, invitationId: undefined } - E.runSync(contacts.update(updated)) + E.runSync(contacts.effect.update(updated)) } } diff --git a/app/hooks/useMemberInvitationGenerator.tsx b/app/hooks/useMemberInvitationGenerator.tsx index 9414e316..20c608a8 100644 --- a/app/hooks/useMemberInvitationGenerator.tsx +++ b/app/hooks/useMemberInvitationGenerator.tsx @@ -28,7 +28,7 @@ export function useMemberInvitationGenerator(contact?: ExtendedContact) { const { id } = team.inviteMember({ seed, expiration, maxUses }) // Record the invitation on the contact's document - E.runSync(contacts.update({ ...contact, invitationId: id })) + E.runSync(contacts.effect.update({ ...contact, invitationId: id })) // The "invitation code" that we give the member is the shareId + the invitation seed const shareId = getShareId(team) diff --git a/app/lib/csvToDoneEntries.ts b/app/lib/csvToDoneEntries.ts index 94d7f0c9..1d71cd2d 100644 --- a/app/lib/csvToDoneEntries.ts +++ b/app/lib/csvToDoneEntries.ts @@ -23,13 +23,13 @@ export const csvToDoneEntries = (csvData: string) => const { input, index } = row return E.gen(function* () { const contacts = yield* ProvidedContacts - const contact = yield* contacts.findBy("userName", row.userName.toLowerCase()) + const contact = yield* contacts.effect.findBy("userName", row.userName.toLowerCase()) // `likes` comes in as as serialized array of user names; need to convert that to contactIDs const likesUserNames = JSON.parse(row.likes) as string[] const likes = [] as ContactId[] for (const userName of likesUserNames) { - const contact = yield* contacts.findBy("userName", userName.toLowerCase()) + const contact = yield* contacts.effect.findBy("userName", userName.toLowerCase()) likes.push(contact.id) } diff --git a/app/lib/csvToTimeEntries.ts b/app/lib/csvToTimeEntries.ts index b86c2141..c8ddc18a 100644 --- a/app/lib/csvToTimeEntries.ts +++ b/app/lib/csvToTimeEntries.ts @@ -29,9 +29,10 @@ export const csvToTimeEntries = (csvData: string) => const projects = yield* ProvidedProjects const clients = yield* ProvidedClients - const contact = yield* contacts.findBy("userName", row.userName.toLowerCase()) + const contact = yield* contacts.effect.findBy("userName", row.userName.toLowerCase()) const project = yield* projects.findByCode(row.project) - const client = row.client.length > 0 ? yield* clients.findBy("code", row.client) : undefined + const client = + row.client.length > 0 ? yield* clients.effect.findBy("code", row.client) : undefined return yield* S.decode(TimeEntry)({ ...row, diff --git a/app/routes/_private+/devtools+/danger+/_danger.tsx b/app/routes/_private+/devtools+/danger+/_danger.tsx index 789f1caa..baaaad9c 100644 --- a/app/routes/_private+/devtools+/danger+/_danger.tsx +++ b/app/routes/_private+/devtools+/danger+/_danger.tsx @@ -27,9 +27,9 @@ export default function DangerPage() { content: (
E.runSync(doneEntries.add(done))} - destroyAll={() => E.runSync(doneEntries.destroyAll())} + contacts={E.runSync(contacts.effect.all())} + add={done => E.runSync(doneEntries.effect.add(done))} + destroyAll={() => E.runSync(doneEntries.effect.destroyAll())} />
), @@ -38,9 +38,9 @@ export default function DangerPage() { heading: "Import dones data", content: ( E.runSync(doneEntries.add(new DoneEntry(done)))} - destroyAll={() => E.runSync(doneEntries.destroyAll())} + contacts={E.runSync(contacts.effect.all())} + add={done => E.runSync(doneEntries.effect.add(new DoneEntry(done)))} + destroyAll={() => E.runSync(doneEntries.effect.destroyAll())} /> ), }, @@ -49,11 +49,11 @@ export default function DangerPage() { content: (
E.runSync(timeEntries.add(t))} - destroyAll={() => E.runSync(timeEntries.destroyAll())} + contacts={E.runSync(contacts.effect.all())} + clients={E.runSync(clients.effect.all())} + projects={E.runSync(projects.effect.all())} + add={t => E.runSync(timeEntries.effect.add(t))} + destroyAll={() => E.runSync(timeEntries.effect.destroyAll())} />
), @@ -64,11 +64,11 @@ export default function DangerPage() {
E.runSync(timeEntries.destroyAll())} - add={timeEntry => E.runSync(timeEntries.add(timeEntry))} - contacts={E.runSync(contacts.all())} - clients={E.runSync(clients.all())} - projects={E.runSync(projects.all())} + destroyAll={() => E.runSync(timeEntries.effect.destroyAll())} + add={timeEntry => E.runSync(timeEntries.effect.add(timeEntry))} + contacts={E.runSync(contacts.effect.all())} + clients={E.runSync(clients.effect.all())} + projects={E.runSync(projects.effect.all())} />
), diff --git a/app/routes/_private+/dones+/$date.tsx b/app/routes/_private+/dones+/$date.tsx index c660abad..ec272e83 100644 --- a/app/routes/_private+/dones+/$date.tsx +++ b/app/routes/_private+/dones+/$date.tsx @@ -11,7 +11,7 @@ export default function Dones$DatePage() { const { start } = useSelectedWeek() const { self, contacts } = useTeam() - const dones = E.runSync(doneEntries.findBy("week", start)) + const dones = E.runSync(doneEntries.effect.findBy("week", start)) return ( E.runSync(doneEntries.update({ id, likes }))} + updateLikes={(id, likes) => E.runSync(doneEntries.effect.update({ id, likes }))} /> diff --git a/app/routes/_private+/hours+/$year.tsx b/app/routes/_private+/hours+/$year.tsx index af109de2..8bccec46 100644 --- a/app/routes/_private+/hours+/$year.tsx +++ b/app/routes/_private+/hours+/$year.tsx @@ -16,7 +16,7 @@ export default function Hours$YearPage() { useRedirect({ from: "/hours", to: `/hours/${currentYear}`, condition: year > currentYear }) - const years = Arr.dedupe(E.runSync(timeEntries.all()).map(entry => entry.date.year())) + const years = Arr.dedupe(E.runSync(timeEntries.effect.all()).map(entry => entry.date.year())) const minYear = Math.min(...years) const maxYear = Math.max(...years) diff --git a/app/routes/_private+/team+/members+/invite.tsx b/app/routes/_private+/team+/members+/invite.tsx index 19b8c5ca..910a349f 100644 --- a/app/routes/_private+/team+/members+/invite.tsx +++ b/app/routes/_private+/team+/members+/invite.tsx @@ -14,7 +14,7 @@ export default function MembersInvitePage() { const navigate = useNavigate() // look up the contact information for the user we're inviting - const contact = E.runSync(contacts.find(userId)) + const contact = E.runSync(contacts.effect.find(userId)) // generate an invitation code for the contact const invitationCode = useMemberInvitationGenerator(contact) diff --git a/app/routes/_private+/team+/members+/remove.tsx b/app/routes/_private+/team+/members+/remove.tsx index f52ae2a7..6286fb27 100644 --- a/app/routes/_private+/team+/members+/remove.tsx +++ b/app/routes/_private+/team+/members+/remove.tsx @@ -17,7 +17,7 @@ export default function RemovePage() { // Only admins can remove members if (!self?.isAdmin) return null - const contact = E.runSync(contacts.find(userId)) + const contact = E.runSync(contacts.effect.find(userId)) return ( { team.remove(contact.id) - contacts.destroy(contact.id) + contacts.effect.destroy(contact.id) }} /> ) diff --git a/app/schema/ClientFromId.ts b/app/schema/ClientFromId.ts index ef80ffd1..488f408f 100644 --- a/app/schema/ClientFromId.ts +++ b/app/schema/ClientFromId.ts @@ -7,7 +7,7 @@ export const ClientFromId = S.transformOrFail(ClientId, S.typeSchema(Client), { decode: (id, _, ast) => E.gen(function* () { const clients = yield* ProvidedClients - return yield* clients.find(id) + return yield* clients.effect.find(id) }).pipe( E.mapError(error => { return new ParseResult.Type(ast, id, error.message) diff --git a/app/schema/ContactFromId.ts b/app/schema/ContactFromId.ts index 2beb605e..52be53be 100644 --- a/app/schema/ContactFromId.ts +++ b/app/schema/ContactFromId.ts @@ -7,7 +7,7 @@ export const ContactFromId = S.transformOrFail(ContactId, S.typeSchema(Contact), decode: (id, _, ast) => E.gen(function* () { const contacts = yield* ProvidedContacts - return yield* contacts.find(id) + return yield* contacts.effect.find(id) }).pipe( E.mapError(error => { return new ParseResult.Type(ast, id, error.message) diff --git a/app/schema/ProjectCollection.ts b/app/schema/ProjectCollection.ts index d2e22226..7ac419ac 100644 --- a/app/schema/ProjectCollection.ts +++ b/app/schema/ProjectCollection.ts @@ -27,12 +27,12 @@ export class ProjectCollection extends Collection<"projects", Project> { // see if the code matches a unique fullCode, e.g. `Feature: API` or `Out` const fullCode = makeFullCode(code, subCode) - const fullCodeMatches = yield* E.either(_this.findBy("fullCode", fullCode)) + const fullCodeMatches = yield* E.either(_this.effect.findBy("fullCode", fullCode)) if (Either.isRight(fullCodeMatches)) return fullCodeMatches.right if (!subCode) { // see if the code matches a unique subcode, e.g. `Training` or `Project X` - const subCodeMatches = yield* _this.findBy("subCode", code) + const subCodeMatches = yield* _this.effect.findBy("subCode", code) if (subCodeMatches.length === 0) { return yield* E.fail(new ProjectCodeNotFoundError({ input })) diff --git a/app/schema/ProjectFromId.ts b/app/schema/ProjectFromId.ts index 3dda8b10..90e0c54d 100644 --- a/app/schema/ProjectFromId.ts +++ b/app/schema/ProjectFromId.ts @@ -7,7 +7,7 @@ export const ProjectFromId = S.transformOrFail(ProjectId, S.typeSchema(Project), decode: (id, _, ast) => E.gen(function* () { const projects = yield* ProvidedProjects - return yield* projects.find(id) + return yield* projects.effect.find(id) }).pipe(E.mapError(error => new ParseResult.Type(ast, id, error.message))), encode: p => ParseResult.succeed(ProjectId.make(p.id)), diff --git a/app/schema/lib/parseClient.ts b/app/schema/lib/parseClient.ts index b0ecbaac..4e163270 100644 --- a/app/schema/lib/parseClient.ts +++ b/app/schema/lib/parseClient.ts @@ -45,7 +45,7 @@ export const parseClient = (input: string) => if (results.length === 0) return { client: undefined, text: "" } const { code, text } = results[0] - const client = yield* clients.findBy("code", code) + const client = yield* clients.effect.findBy("code", code) return { text, client } }) diff --git a/app/ui/AutocompleteMenu.tsx b/app/ui/AutocompleteMenu.tsx index 7fe83491..6fd65817 100644 --- a/app/ui/AutocompleteMenu.tsx +++ b/app/ui/AutocompleteMenu.tsx @@ -104,7 +104,7 @@ export const getAutocompleteItems = < const { collection, property } = mode - return E.runSync(collection.all()) + return E.runSync(collection.effect.all()) .map(item => String(item[property])) .filter(value => value.toLowerCase().includes(query.toLowerCase())) .sort((a, b) => { diff --git a/app/ui/DailyDones.tsx b/app/ui/DailyDones.tsx index 40490eb1..c0cf5ae4 100644 --- a/app/ui/DailyDones.tsx +++ b/app/ui/DailyDones.tsx @@ -12,7 +12,7 @@ import { DoneInput } from "ui/DoneInput" export const DailyDones = ({ date, doneEntries, self }: Props) => { const [focus, setFocus] = useState(-1) // nothing focused by default - const dones = E.runSync(doneEntries.findBy("date", date)) // for this day + const dones = E.runSync(doneEntries.effect.findBy("date", date)) // for this day .filter(({ contactId }) => contactId === self.id) // for this contact const focusNext = () => setFocus((f: number) => Math.min(f + 1, dones.length + 1)) @@ -27,8 +27,8 @@ export const DailyDones = ({ date, doneEntries, self }: Props) => { E.runSync(doneEntries.update({ id: done.id, content }))} - onDestroy={() => E.runSync(doneEntries.destroy(done.id))} + onUpdate={content => E.runSync(doneEntries.effect.update({ id: done.id, content }))} + onDestroy={() => E.runSync(doneEntries.effect.destroy(done.id))} isFocused={focus === index} onFocus={setFocus} onFocusNext={focusNext} @@ -54,7 +54,9 @@ export const DailyDones = ({ date, doneEntries, self }: Props) => { onFocusPrev={focusPrev} onDestroy={() => {}} onChange={content => { - E.runSync(doneEntries.add(new DoneEntry({ date, contactId: self.id, content }))) + E.runSync( + doneEntries.effect.add(new DoneEntry({ date, contactId: self.id, content })), + ) setFocus(dones.length + 1) }} /> diff --git a/app/ui/DailyTimeEntries.tsx b/app/ui/DailyTimeEntries.tsx index 45a904b7..a1f74e73 100644 --- a/app/ui/DailyTimeEntries.tsx +++ b/app/ui/DailyTimeEntries.tsx @@ -21,7 +21,7 @@ export const DailyTimeEntries = ({ }: Props) => { const [focus, setFocus] = useState(-1) // nothing focused by default - const entries = E.runSync(timeEntries.findBy("date", date)) // for this day + const entries = E.runSync(timeEntries.effect.findBy("date", date)) // for this day .filter(({ contactId }) => contactId === self.id) // for this contact const onFocusNext = () => setFocus((f: number) => f + 1) @@ -59,13 +59,15 @@ export const DailyTimeEntries = ({ {...{ index, date, projects, clients, self, onFocusNext, onFocusPrev, onDiscard }} isFocused={focus === index} onFocus={setFocus} - onDestroy={() => E.runSync(timeEntries.destroy(timeEntry.id))} - onCommit={e => E.runSync(timeEntries.update({ ...e, id: timeEntry.id }))} + onDestroy={() => E.runSync(timeEntries.effect.destroy(timeEntry.id))} + onCommit={e => E.runSync(timeEntries.effect.update({ ...e, id: timeEntry.id }))} /> :
- E.runSync(timeEntries.destroy(timeEntry.id))} /> + E.runSync(timeEntries.effect.destroy(timeEntry.id))} + />
} @@ -81,7 +83,7 @@ export const DailyTimeEntries = ({ {...{ date, projects, clients, self, onFocusNext, onFocusPrev, onDiscard }} isFocused={focus === entries.length} onFocus={setFocus} - onCommit={e => E.runSync(timeEntries.add(e))} + onCommit={e => E.runSync(timeEntries.effect.add(e))} /> diff --git a/app/ui/HoursReport.tsx b/app/ui/HoursReport.tsx index 56102385..46289ea7 100644 --- a/app/ui/HoursReport.tsx +++ b/app/ui/HoursReport.tsx @@ -18,7 +18,7 @@ import { Avatar } from "./Avatar" import { CenteredLayout } from "./layouts/CenteredLayout" export const HoursReport = ({ year, contacts, timeEntries }: Props) => { - const entries = E.runSync(timeEntries.findBy("year", year)) + const entries = E.runSync(timeEntries.effect.findBy("year", year)) if (entries.length === 0) { return ( @@ -33,7 +33,7 @@ export const HoursReport = ({ year, contacts, timeEntries }: Props) => { ) } - const allContacts = E.runSync(contacts.all()) + const allContacts = E.runSync(contacts.effect.all()) /** Which contacts have any hours data at all? */ const reportingContacts = allContacts.filter(c => entries.some(e => e.contactId === c.id)) diff --git a/app/ui/MyWeek.tsx b/app/ui/MyWeek.tsx index 4d5e0255..e37f6dc0 100644 --- a/app/ui/MyWeek.tsx +++ b/app/ui/MyWeek.tsx @@ -28,7 +28,7 @@ export const MyWeek = ({ }: Props) => { const days = getDaysOfWeek(start).filter(date => showWeekends || !isWeekend(date)) - const myTimeEntries = E.runSync(timeEntries.findBy("week", start)) // for the week being shown + const myTimeEntries = E.runSync(timeEntries.effect.findBy("week", start)) // for the week being shown // only my entries .filter(({ contactId }) => contactId === self.id) diff --git a/app/ui/stories/HoursReport.stories.tsx b/app/ui/stories/HoursReport.stories.tsx index a8e93474..175b14c4 100644 --- a/app/ui/stories/HoursReport.stories.tsx +++ b/app/ui/stories/HoursReport.stories.tsx @@ -85,7 +85,7 @@ export const OneEntry = makeStory({ contactId: contacts[0].id, date: LocalDate.parse("2024-01-07"), duration: 60, - project: E.runSync(projectCollection.findBy("code", "Out"))[0], + project: E.runSync(projectCollection.effect.findBy("code", "Out"))[0], input: "1h #out", }), ], From 8529872c741452db058228053df68356307822bb Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 15:00:38 +0100 Subject: [PATCH 10/12] use sync api where appropriate --- .../Database/test/Collection.benchmark.ts | 3 +- app/context/Database/test/Collection.test.ts | 52 +++++++++---------- app/hooks/useInvitation.tsx | 3 +- app/hooks/useMemberInvitationGenerator.tsx | 3 +- .../_private+/devtools+/danger+/_danger.tsx | 33 ++++++------ app/routes/_private+/dones+/$date.tsx | 5 +- app/routes/_private+/hours+/$year.tsx | 4 +- .../_private+/team+/members+/invite.tsx | 3 +- .../_private+/team+/members+/remove.tsx | 3 +- app/ui/AutocompleteMenu.tsx | 4 +- app/ui/DailyDones.tsx | 7 +-- app/ui/DailyTimeEntries.tsx | 14 +++-- app/ui/HoursReport.tsx | 5 +- app/ui/MyWeek.tsx | 4 +- app/ui/stories/HoursReport.stories.tsx | 3 +- 15 files changed, 68 insertions(+), 78 deletions(-) diff --git a/app/context/Database/test/Collection.benchmark.ts b/app/context/Database/test/Collection.benchmark.ts index dd2b6682..0450756d 100644 --- a/app/context/Database/test/Collection.benchmark.ts +++ b/app/context/Database/test/Collection.benchmark.ts @@ -2,7 +2,6 @@ import { LocalDate } from "@js-joda/core" import { clients } from "data/clients" import { contacts } from "data/contacts" import { projects } from "data/projects" -import { E } from "lib/Effect" import { generateTimeEntries } from "lib/generateTimeEntries" import { TimeEntryCollection } from "schema/TimeEntryCollection" import { bench, describe } from "vitest" @@ -17,7 +16,7 @@ const addEntries = (weekCount: number) => { }) const collection = new TimeEntryCollection([]) - E.runSync(collection.effect.add(entries)) + collection.add(entries) } describe("collection.add()", () => { diff --git a/app/context/Database/test/Collection.test.ts b/app/context/Database/test/Collection.test.ts index 27f76461..4bac5b4e 100644 --- a/app/context/Database/test/Collection.test.ts +++ b/app/context/Database/test/Collection.test.ts @@ -56,7 +56,7 @@ describe("Collection", () => { describe("constructor", () => { it("instantiates the collection", () => { const { collection } = setup() - expect(E.runSync(collection.effect.all())).toHaveLength(14) + expect(collection.all()).toHaveLength(14) }) }) @@ -64,7 +64,7 @@ describe("Collection", () => { it("finds an item by ID", () => { const { dones, collection } = setup() const { id } = dones[0] - const item = E.runSync(collection.effect.find(id)) + const item = collection.find(id) expect(item).toEqual(dones[0]) }) @@ -77,19 +77,19 @@ describe("Collection", () => { it("finds an item by a non-unique indexed property", () => { const { collection } = setup() - const items = E.runSync(collection.effect.findBy("contactId", alice.id)) + const items = collection.findBy("contactId", alice.id) expect(items).toHaveLength(7) }) it("finds an item using an accessor function", () => { const { collection } = setup() - const items = E.runSync(collection.effect.findBy("week", "2024-11-03")) + const items = collection.findBy("week", "2024-11-03") expect(items).toHaveLength(8) }) it("finds an item by year", () => { const { collection } = setup() - const items = E.runSync(collection.effect.findBy("year", 2024)) + const items = collection.findBy("year", 2024) expect(items).toHaveLength(14) }) }) @@ -102,16 +102,16 @@ describe("Collection", () => { content: "new", date: LocalDate.parse("2024-11-04"), }) - const result = E.runSync(collection.effect.add(newDone)) + const result = collection.add(newDone) expect(result).toBe(newDone) - expect(E.runSync(collection.effect.all())).toHaveLength(15) - expect(E.runSync(collection.effect.findBy("contactId", alice.id))).toHaveLength(8) - expect(E.runSync(collection.effect.findBy("week", "2024-11-03"))).toHaveLength(9) + expect(collection.all()).toHaveLength(15) + expect(collection.findBy("contactId", alice.id)).toHaveLength(8) + expect(collection.findBy("week", "2024-11-03")).toHaveLength(9) }) it("adds multiple items to the collection", () => { const { collection } = setup() - expect(E.runSync(collection.effect.all())).toHaveLength(14) + expect(collection.all()).toHaveLength(14) // generate a bunch of dones const dones = generateDones({ @@ -121,39 +121,39 @@ describe("Collection", () => { enthusiasm: 0.1, contacts, }) - const result = E.runSync(collection.effect.add(dones)) + const result = collection.add(dones) expect(result).toBe(dones) - expect(E.runSync(collection.effect.all())).toHaveLength(224) + expect(collection.all()).toHaveLength(224) }) }) describe("update", () => { it("updates a string value", () => { const { collection } = setup() - const dones = E.runSync(collection.effect.all()) + const dones = collection.all() const item = dones[0] - E.runSync(collection.effect.update({ ...item, content: "updated" })) - const updated = E.runSync(collection.effect.find(item.id)) + collection.update({ ...item, content: "updated" }) + const updated = collection.find(item.id) expect(updated.content).toBe("updated") }) it("updates a date value", () => { const { collection } = setup() - const dones = E.runSync(collection.effect.all()) + const dones = collection.all() const item = dones[0] - E.runSync(collection.effect.update({ ...item, date: LocalDate.parse("2024-11-05") })) + collection.update({ ...item, date: LocalDate.parse("2024-11-05") }) - const updated = E.runSync(collection.effect.find(item.id)) + const updated = collection.find(item.id) expect(updated.date.toString()).toBe("2024-11-05") }) it("updates an array value", () => { const { collection } = setup() - const dones = E.runSync(collection.effect.all()) + const dones = collection.all() const item = dones[0] - E.runSync(collection.effect.update({ ...item, likes: [bob] })) + collection.update({ ...item, likes: [bob] }) - const updated = E.runSync(collection.effect.find(item.id)) + const updated = collection.find(item.id) expect(updated.likes).toEqual([bob]) }) }) @@ -161,19 +161,19 @@ describe("Collection", () => { describe("destroy", () => { it("removes an item from the collection", () => { const { collection } = setup() - const dones = E.runSync(collection.effect.all()) + const dones = collection.all() const item = dones[0] - E.runSync(collection.effect.destroy(item.id)) + collection.destroy(item.id) - expect(E.runSync(collection.effect.all())).toHaveLength(13) + expect(collection.all()).toHaveLength(13) }) }) describe("destroyAll", () => { it("removes all items from the collection", () => { const { collection } = setup() - E.runSync(collection.effect.destroyAll()) - expect(E.runSync(collection.effect.all())).toHaveLength(0) + collection.destroyAll() + expect(collection.all()).toHaveLength(0) }) }) }) diff --git a/app/hooks/useInvitation.tsx b/app/hooks/useInvitation.tsx index 60f27b31..6f2aba3d 100644 --- a/app/hooks/useInvitation.tsx +++ b/app/hooks/useInvitation.tsx @@ -1,6 +1,5 @@ import type { Invitation } from "@localfirst/auth" import { useTeam } from "hooks/useTeam" -import { E } from "lib/Effect" import { useEffect, useState } from "react" import { type ExtendedContact } from "schema/Contact" import { useDatabase } from "./useDatabase" @@ -23,7 +22,7 @@ export function useInvitation(contact: ExtendedContact) { // update the contact record const updated = { ...contact, invitationId: undefined } - E.runSync(contacts.effect.update(updated)) + contacts.update(updated) } } diff --git a/app/hooks/useMemberInvitationGenerator.tsx b/app/hooks/useMemberInvitationGenerator.tsx index 20c608a8..f203ded9 100644 --- a/app/hooks/useMemberInvitationGenerator.tsx +++ b/app/hooks/useMemberInvitationGenerator.tsx @@ -6,7 +6,6 @@ import { getShareId } from "@localfirst/auth-provider-automerge-repo" import { randomKey } from "@localfirst/crypto" import { useTeam } from "hooks/useTeam" import { HOUR } from "lib/constants" -import { E } from "lib/Effect" import { useEffect, useState } from "react" import { type ExtendedContact } from "schema/Contact" import { useDatabase } from "./useDatabase" @@ -28,7 +27,7 @@ export function useMemberInvitationGenerator(contact?: ExtendedContact) { const { id } = team.inviteMember({ seed, expiration, maxUses }) // Record the invitation on the contact's document - E.runSync(contacts.effect.update({ ...contact, invitationId: id })) + contacts.update({ ...contact, invitationId: id }) // The "invitation code" that we give the member is the shareId + the invitation seed const shareId = getShareId(team) diff --git a/app/routes/_private+/devtools+/danger+/_danger.tsx b/app/routes/_private+/devtools+/danger+/_danger.tsx index baaaad9c..fc4e014e 100644 --- a/app/routes/_private+/devtools+/danger+/_danger.tsx +++ b/app/routes/_private+/devtools+/danger+/_danger.tsx @@ -1,6 +1,5 @@ import { Alert, AlertDescription } from "@ui/alert" import { useDatabase } from "hooks/useDatabase" -import { E } from "lib/Effect" import { DoneEntry } from "schema/DoneEntry" import { DoneEntryGenerator } from "./ui/DoneEntryGenerator" import { DoneEntryImporter } from "./ui/DoneEntryImporter" @@ -27,9 +26,9 @@ export default function DangerPage() { content: (
E.runSync(doneEntries.effect.add(done))} - destroyAll={() => E.runSync(doneEntries.effect.destroyAll())} + contacts={contacts.all()} + add={done => doneEntries.add(done)} + destroyAll={() => doneEntries.destroyAll()} />
), @@ -38,9 +37,9 @@ export default function DangerPage() { heading: "Import dones data", content: ( E.runSync(doneEntries.effect.add(new DoneEntry(done)))} - destroyAll={() => E.runSync(doneEntries.effect.destroyAll())} + contacts={contacts.all()} + add={done => doneEntries.add(new DoneEntry(done))} + destroyAll={() => doneEntries.destroyAll()} /> ), }, @@ -49,11 +48,11 @@ export default function DangerPage() { content: (
E.runSync(timeEntries.effect.add(t))} - destroyAll={() => E.runSync(timeEntries.effect.destroyAll())} + contacts={contacts.all()} + clients={clients.all()} + projects={projects.all()} + add={t => timeEntries.add(t)} + destroyAll={() => timeEntries.destroyAll()} />
), @@ -64,11 +63,11 @@ export default function DangerPage() {
E.runSync(timeEntries.effect.destroyAll())} - add={timeEntry => E.runSync(timeEntries.effect.add(timeEntry))} - contacts={E.runSync(contacts.effect.all())} - clients={E.runSync(clients.effect.all())} - projects={E.runSync(projects.effect.all())} + destroyAll={() => timeEntries.destroyAll()} + add={timeEntry => timeEntries.add(timeEntry)} + contacts={contacts.all()} + clients={clients.all()} + projects={projects.all()} />
), diff --git a/app/routes/_private+/dones+/$date.tsx b/app/routes/_private+/dones+/$date.tsx index ec272e83..d65f8d7b 100644 --- a/app/routes/_private+/dones+/$date.tsx +++ b/app/routes/_private+/dones+/$date.tsx @@ -1,7 +1,6 @@ import { useDatabase } from "hooks/useDatabase" import { useSelectedWeek } from "hooks/useSelectedWeek" import { useTeam } from "hooks/useTeam" -import { E } from "lib/Effect" import { PageLayout } from "ui/layouts/PageLayout" import { TeamDones } from "ui/TeamDones" import { WeekNav } from "ui/WeekNav" @@ -11,7 +10,7 @@ export default function Dones$DatePage() { const { start } = useSelectedWeek() const { self, contacts } = useTeam() - const dones = E.runSync(doneEntries.effect.findBy("week", start)) + const dones = doneEntries.findBy("week", start) return ( E.runSync(doneEntries.effect.update({ id, likes }))} + updateLikes={(id, likes) => doneEntries.update({ id, likes })} /> diff --git a/app/routes/_private+/hours+/$year.tsx b/app/routes/_private+/hours+/$year.tsx index 8bccec46..3d4032f9 100644 --- a/app/routes/_private+/hours+/$year.tsx +++ b/app/routes/_private+/hours+/$year.tsx @@ -2,7 +2,7 @@ import { useDatabase } from "hooks/useDatabase" import { useRedirect } from "hooks/useRedirect" import { useSelectedYear } from "hooks/useSelectedYear" import { useTeam } from "hooks/useTeam" -import { Array as Arr, E } from "lib/Effect" +import { Array as Arr } from "lib/Effect" import { getCurrentYear } from "lib/getCurrentYear" import { HoursReport } from "ui/HoursReport" import { PageLayout } from "ui/layouts/PageLayout" @@ -16,7 +16,7 @@ export default function Hours$YearPage() { useRedirect({ from: "/hours", to: `/hours/${currentYear}`, condition: year > currentYear }) - const years = Arr.dedupe(E.runSync(timeEntries.effect.all()).map(entry => entry.date.year())) + const years = Arr.dedupe(timeEntries.all().map(entry => entry.date.year())) const minYear = Math.min(...years) const maxYear = Math.max(...years) diff --git a/app/routes/_private+/team+/members+/invite.tsx b/app/routes/_private+/team+/members+/invite.tsx index 910a349f..252144ae 100644 --- a/app/routes/_private+/team+/members+/invite.tsx +++ b/app/routes/_private+/team+/members+/invite.tsx @@ -1,7 +1,6 @@ import { useDatabase } from "hooks/useDatabase" import { useMemberInvitationGenerator } from "hooks/useMemberInvitationGenerator" import { useTeam } from "hooks/useTeam" -import { E } from "lib/Effect" import { useLocation, useNavigate } from "react-router-dom" import { type ContactId } from "schema/Contact" import { InviteMemberDialog } from "ui/InviteMemberDialog" @@ -14,7 +13,7 @@ export default function MembersInvitePage() { const navigate = useNavigate() // look up the contact information for the user we're inviting - const contact = E.runSync(contacts.effect.find(userId)) + const contact = contacts.find(userId) // generate an invitation code for the contact const invitationCode = useMemberInvitationGenerator(contact) diff --git a/app/routes/_private+/team+/members+/remove.tsx b/app/routes/_private+/team+/members+/remove.tsx index 6286fb27..86761628 100644 --- a/app/routes/_private+/team+/members+/remove.tsx +++ b/app/routes/_private+/team+/members+/remove.tsx @@ -1,6 +1,5 @@ import { useDatabase } from "hooks/useDatabase" import { useTeam } from "hooks/useTeam" -import { E } from "lib/Effect" import { useLocation, useNavigate } from "react-router-dom" import type { ContactId } from "schema/Contact" import { RemoveMemberDialog } from "ui/RemoveMemberDialog" @@ -17,7 +16,7 @@ export default function RemovePage() { // Only admins can remove members if (!self?.isAdmin) return null - const contact = E.runSync(contacts.effect.find(userId)) + const contact = contacts.find(userId) return ( String(item[property])) .filter(value => value.toLowerCase().includes(query.toLowerCase())) .sort((a, b) => { diff --git a/app/ui/DailyDones.tsx b/app/ui/DailyDones.tsx index c0cf5ae4..e3ca45fd 100644 --- a/app/ui/DailyDones.tsx +++ b/app/ui/DailyDones.tsx @@ -12,7 +12,8 @@ import { DoneInput } from "ui/DoneInput" export const DailyDones = ({ date, doneEntries, self }: Props) => { const [focus, setFocus] = useState(-1) // nothing focused by default - const dones = E.runSync(doneEntries.effect.findBy("date", date)) // for this day + const dones = doneEntries + .findBy("date", date) // for this day .filter(({ contactId }) => contactId === self.id) // for this contact const focusNext = () => setFocus((f: number) => Math.min(f + 1, dones.length + 1)) @@ -27,8 +28,8 @@ export const DailyDones = ({ date, doneEntries, self }: Props) => { E.runSync(doneEntries.effect.update({ id: done.id, content }))} - onDestroy={() => E.runSync(doneEntries.effect.destroy(done.id))} + onUpdate={content => doneEntries.update({ id: done.id, content })} + onDestroy={() => doneEntries.destroy(done.id)} isFocused={focus === index} onFocus={setFocus} onFocusNext={focusNext} diff --git a/app/ui/DailyTimeEntries.tsx b/app/ui/DailyTimeEntries.tsx index a1f74e73..2663bc8c 100644 --- a/app/ui/DailyTimeEntries.tsx +++ b/app/ui/DailyTimeEntries.tsx @@ -1,6 +1,5 @@ import type { LocalDate } from "@js-joda/core" import { cx } from "lib/cx" -import { E } from "lib/Effect" import { useState } from "react" import { type ClientCollection } from "schema/ClientCollection" import type { Contact } from "schema/Contact" @@ -21,7 +20,8 @@ export const DailyTimeEntries = ({ }: Props) => { const [focus, setFocus] = useState(-1) // nothing focused by default - const entries = E.runSync(timeEntries.effect.findBy("date", date)) // for this day + const entries = timeEntries + .findBy("date", date) // for this day .filter(({ contactId }) => contactId === self.id) // for this contact const onFocusNext = () => setFocus((f: number) => f + 1) @@ -59,15 +59,13 @@ export const DailyTimeEntries = ({ {...{ index, date, projects, clients, self, onFocusNext, onFocusPrev, onDiscard }} isFocused={focus === index} onFocus={setFocus} - onDestroy={() => E.runSync(timeEntries.effect.destroy(timeEntry.id))} - onCommit={e => E.runSync(timeEntries.effect.update({ ...e, id: timeEntry.id }))} + onDestroy={() => timeEntries.destroy(timeEntry.id)} + onCommit={e => timeEntries.update({ ...e, id: timeEntry.id })} /> :
- E.runSync(timeEntries.effect.destroy(timeEntry.id))} - /> + timeEntries.destroy(timeEntry.id)} />
} @@ -83,7 +81,7 @@ export const DailyTimeEntries = ({ {...{ date, projects, clients, self, onFocusNext, onFocusPrev, onDiscard }} isFocused={focus === entries.length} onFocus={setFocus} - onCommit={e => E.runSync(timeEntries.effect.add(e))} + onCommit={e => timeEntries.add(e)} /> diff --git a/app/ui/HoursReport.tsx b/app/ui/HoursReport.tsx index 46289ea7..b1ca2d19 100644 --- a/app/ui/HoursReport.tsx +++ b/app/ui/HoursReport.tsx @@ -1,7 +1,6 @@ import { LocalDate } from "@js-joda/core" import { asPercentage } from "lib/asPercentage" import { cx } from "lib/cx" -import { E } from "lib/Effect" import { formatDate } from "lib/formatDate" import { formatDateRange } from "lib/formatDateRange" import { getSunday } from "lib/getSunday" @@ -18,7 +17,7 @@ import { Avatar } from "./Avatar" import { CenteredLayout } from "./layouts/CenteredLayout" export const HoursReport = ({ year, contacts, timeEntries }: Props) => { - const entries = E.runSync(timeEntries.effect.findBy("year", year)) + const entries = timeEntries.findBy("year", year) if (entries.length === 0) { return ( @@ -33,7 +32,7 @@ export const HoursReport = ({ year, contacts, timeEntries }: Props) => { ) } - const allContacts = E.runSync(contacts.effect.all()) + const allContacts = contacts.all() /** Which contacts have any hours data at all? */ const reportingContacts = allContacts.filter(c => entries.some(e => e.contactId === c.id)) diff --git a/app/ui/MyWeek.tsx b/app/ui/MyWeek.tsx index e37f6dc0..a86460fd 100644 --- a/app/ui/MyWeek.tsx +++ b/app/ui/MyWeek.tsx @@ -1,6 +1,5 @@ import { LocalDate } from "@js-joda/core" import { cx } from "lib/cx" -import { E } from "lib/Effect" import { DAY_OF_MONTH, DAY_OF_WEEK, formatDate } from "lib/formatDate" import { formatDuration } from "lib/formatDuration" import { getDaysOfWeek } from "lib/getDaysOfWeek" @@ -28,7 +27,8 @@ export const MyWeek = ({ }: Props) => { const days = getDaysOfWeek(start).filter(date => showWeekends || !isWeekend(date)) - const myTimeEntries = E.runSync(timeEntries.effect.findBy("week", start)) // for the week being shown + const myTimeEntries = timeEntries + .findBy("week", start) // for the week being shown // only my entries .filter(({ contactId }) => contactId === self.id) diff --git a/app/ui/stories/HoursReport.stories.tsx b/app/ui/stories/HoursReport.stories.tsx index 175b14c4..d3fbaad7 100644 --- a/app/ui/stories/HoursReport.stories.tsx +++ b/app/ui/stories/HoursReport.stories.tsx @@ -3,7 +3,6 @@ import type { Decorator, Meta, StoryObj } from "@storybook/react" import { clients } from "data/clients" import { fakeExtendedContacts as contacts } from "data/contacts" import { projects } from "data/projects" -import { E } from "lib/Effect" import { generateTimeEntries } from "lib/generateTimeEntries" import MockDate from "mockdate" import { ContactCollection } from "schema/ContactCollection" @@ -85,7 +84,7 @@ export const OneEntry = makeStory({ contactId: contacts[0].id, date: LocalDate.parse("2024-01-07"), duration: 60, - project: E.runSync(projectCollection.effect.findBy("code", "Out"))[0], + project: projectCollection.findBy("code", "Out")[0], input: "1h #out", }), ], From 88d3211d2ab3afa3cff645f402664e8d5dd19627 Mon Sep 17 00:00:00 2001 From: Herb Caudill Date: Wed, 5 Feb 2025 16:01:04 +0100 Subject: [PATCH 11/12] DailyDones: use sync api --- app/ui/DailyDones.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/ui/DailyDones.tsx b/app/ui/DailyDones.tsx index e3ca45fd..1ce0c1fa 100644 --- a/app/ui/DailyDones.tsx +++ b/app/ui/DailyDones.tsx @@ -55,9 +55,7 @@ export const DailyDones = ({ date, doneEntries, self }: Props) => { onFocusPrev={focusPrev} onDestroy={() => {}} onChange={content => { - E.runSync( - doneEntries.effect.add(new DoneEntry({ date, contactId: self.id, content })), - ) + doneEntries.add(new DoneEntry({ date, contactId: self.id, content })) setFocus(dones.length + 1) }} /> From 5657e04097dc3869c7db04335409097a5a2d8ba0 Mon Sep 17 00:00:00 2001 From: Nathan Gerhart Date: Sun, 23 Feb 2025 13:23:06 -0700 Subject: [PATCH 12/12] Fix lint after merge --- app/lib/test/csvToDoneEntries.test.ts | 8 +------- app/ui/DailyDones.tsx | 1 - 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/app/lib/test/csvToDoneEntries.test.ts b/app/lib/test/csvToDoneEntries.test.ts index bb103407..8ef96a1c 100644 --- a/app/lib/test/csvToDoneEntries.test.ts +++ b/app/lib/test/csvToDoneEntries.test.ts @@ -1,5 +1,4 @@ import { contacts } from "data/contacts" -import { projects } from "data/projects" import { E, pipe } from "lib/Effect" import { type BaseTestCase } from "lib/runTestCases" import { ContactCollection, ProvidedContacts } from "schema/ContactCollection" @@ -62,12 +61,7 @@ const errorPadding = Math.max(...testCases.filter(tc => tc.error).map(tc => labe const TestContacts = new ContactCollection(contacts) const decode = (csv: string) => - pipe( - csv, - csvToDoneEntries, - E.provideService(ProvidedContacts, TestContacts), - E.runSync, - ) + pipe(csv, csvToDoneEntries, E.provideService(ProvidedContacts, TestContacts), E.runSync) for (const testCase of testCases) { const { input, only, skip } = testCase diff --git a/app/ui/DailyDones.tsx b/app/ui/DailyDones.tsx index 1ce0c1fa..c8000182 100644 --- a/app/ui/DailyDones.tsx +++ b/app/ui/DailyDones.tsx @@ -1,6 +1,5 @@ import { type LocalDate } from "@js-joda/core" import { cx } from "lib/cx" -import { E } from "lib/Effect" import { useState } from "react" import { type Contact } from "schema/Contact" import { DoneEntry } from "schema/DoneEntry"