diff --git a/.storybook/main.ts b/.storybook/main.ts index 85d1c763..283dca62 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -34,24 +34,3 @@ function getUiDirs(dir: string = path.join(__dirname, "../app")): string[] { const otherDirs = dirs.filter(dir => dir.name !== "ui") return uiDirs.concat(otherDirs.flatMap(subDir => getUiDirs(fullPath(subDir)).sort())) } - -function getParent(dir: string) { - return path.basename(path.dirname(dir)) -} - -function stripPunctuation(str: string) { - return str.replace(/[^\w\s]/g, "") -} - -function titleCase(str: string) { - return str - .split(" ") - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" ") -} - -function getTitlePrefix(dir: string) { - const parent = getParent(dir) - const title = titleCase(stripPunctuation(parent)).replace("App", "") - return `Components/${title}` -} diff --git a/app/context/Auth/getSyncServer.ts b/app/context/Auth/getSyncServer.ts index b4a8f2fb..e366f646 100644 --- a/app/context/Auth/getSyncServer.ts +++ b/app/context/Auth/getSyncServer.ts @@ -1,6 +1,5 @@ const isDev = !import.meta.env.PROD const devPort = (import.meta.env.VITE_SYNC_SERVER_PORT as string) ?? "3030" -console.log("devPort", devPort) const httpProtocol = isDev ? "http:" : "https:" const wsProtocol = isDev ? "ws:" : "wss:" diff --git a/app/context/Database/Collection.ts b/app/context/Database/Collection.ts index 0749120f..c34bc824 100644 --- a/app/context/Database/Collection.ts +++ b/app/context/Database/Collection.ts @@ -113,17 +113,24 @@ export abstract class Collection< } /** Adds an item to the collection */ - add(item: Item) { + add(arg: Item | Item[]) { + const items = Array.isArray(arg) ? arg : [arg] + return E.gen(this, function* () { - // add the item to all indexes - for (const index of this.allIndexes) yield* index.add(item) + // add each item to all indexes + for (const index of this.allIndexes) { + yield* index.add(items) + } // update automerge-repo - this.change((root: Types.Mutable) => { - const rootElement = root[this.name] - rootElement[item.id] = this.encode(item) - }) - return item + for (const item of items) { + this.change((root: Types.Mutable) => { + const rootElement = root[this.name] + rootElement[item.id] = this.encode(item) + }) + } + + return arg }) } diff --git a/app/context/Database/Indexes.ts b/app/context/Database/Indexes.ts index 3cc99370..b422e5f3 100644 --- a/app/context/Database/Indexes.ts +++ b/app/context/Database/Indexes.ts @@ -36,7 +36,8 @@ export abstract class BaseIndex { if (this.unique && existingItems.length > 0) throw new Error(`Duplicate key '${key}' found while building index`) - return { ...acc, [key]: [...existingItems, item] } + acc[key] = [...existingItems, item] + return acc }, {}) } @@ -58,33 +59,35 @@ export abstract class BaseIndex { } /** Adds a new item. For unique indexes, throws an error if an item with the same key already exists. */ - add(itemToAdd: Item) { - const key = this.accessor(itemToAdd) - const existingItems = this.index[key] ?? [] + add(arg: Item | Item[]) { + const items = Array.isArray(arg) ? arg : [arg] - if (this.unique && existingItems.length > 0) - return E.fail(new KeyExistsError({ field: this.name, value: key })) + for (const item of items) { + const key = this.accessor(item) + + this.index[key] ??= [] + + const existingItems = this.index[key] ?? [] - this.index = { - ...this.index, - [key]: [...existingItems, itemToAdd], + if (this.unique && existingItems.length > 0) + return E.fail(new KeyExistsError({ field: this.name, value: key })) + + this.index[key] = [...existingItems, item] } - return E.succeed(itemToAdd) + return E.succeed(arg) } /** Updates an existing item. Throws an error if there is not an existing item. */ update(item: Item) { return E.gen(this, function* () { const key = this.accessor(item) + const items = [yield* this.find(key)].flat() as Item[] const matchingItem = items.find(i => i.id === item.id) if (!matchingItem) yield* E.fail(new KeyNotFoundError({ field: this.name, value: key })) - this.index = { - ...this.index, - [key]: items.map(i => (i.id === item.id ? item : i)), - } + this.index[key] = items.map(i => (i.id === item.id ? item : i)) }) } @@ -96,10 +99,7 @@ export abstract class BaseIndex { const matchingItem = items.find(i => i.id === item.id) if (!matchingItem) yield* E.fail(new KeyNotFoundError({ field: this.name, value: key })) - this.index = { - ...this.index, - [key]: items.filter(i => i.id !== item.id), - } + this.index[key] = items.filter(i => i.id !== item.id) }) } diff --git a/app/context/Database/test/Collection.benchmark.ts b/app/context/Database/test/Collection.benchmark.ts new file mode 100644 index 00000000..92a732a4 --- /dev/null +++ b/app/context/Database/test/Collection.benchmark.ts @@ -0,0 +1,30 @@ +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 { generateTimeEntries } from "lib/generateTimeEntries" +import { TimeEntryCollection } from "schema/TimeEntryCollection" +import { bench, describe } from "vitest" + +const addEntries = (weekCount: number) => { + const entries = generateTimeEntries({ + clients, + contacts, + projects, + startDate: LocalDate.now(), + weekCount, + }) + + const collection = new TimeEntryCollection([]) + $(collection.add(entries)) +} + +describe("collection.add()", () => { + bench("1 week", async () => addEntries(1)) + bench("5 weeks", async () => addEntries(5)) + bench("10 weeks", async () => addEntries(10)) + // bench("50 weeks", async () => addEntries(50)) +}) + +// describe("collection.all()", () => {}) diff --git a/app/context/Database/test/Collection.test.ts b/app/context/Database/test/Collection.test.ts index d45378bd..a91645df 100644 --- a/app/context/Database/test/Collection.test.ts +++ b/app/context/Database/test/Collection.test.ts @@ -1,5 +1,7 @@ import { LocalDate } from "@js-joda/core" +import { contacts } from "data/contacts" import { $, E, Either } from "lib/Effect" +import { generateDones } from "lib/generateDones" import { Contact } from "schema/Contact" import { DoneEntry } from "schema/DoneEntry" import { DoneEntryCollection } from "schema/DoneEntryCollection" @@ -84,6 +86,12 @@ describe("Collection", () => { const items = $(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)) + expect(items).toHaveLength(14) + }) }) describe("add", () => { @@ -100,6 +108,23 @@ describe("Collection", () => { 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($(collection.all())).toHaveLength(14) + + // generate a bunch of dones + const dones = generateDones({ + today: LocalDate.parse("2024-11-09"), + weeks: 1, + productivity: 3, + enthusiasm: 0.1, + contacts, + }) + const result = $(collection.add(dones)) + expect(result).toBe(dones) + expect($(collection.all())).toHaveLength(224) + }) }) describe("update", () => { @@ -126,10 +151,10 @@ describe("Collection", () => { const { collection } = setup() const dones = $(collection.all()) const item = dones[0] - $(collection.update({ ...item, likes: [bob.id] })) + $(collection.update({ ...item, likes: [bob] })) const updated = $(collection.find(item.id)) - expect(updated.likes).toEqual([bob.id]) + expect(updated.likes).toEqual([bob]) }) }) diff --git a/app/data/contacts.ts b/app/data/contacts.ts index ef6830f0..a015deaa 100644 --- a/app/data/contacts.ts +++ b/app/data/contacts.ts @@ -1,4 +1,4 @@ -import { type ContactId, Contact } from "schema/Contact" +import { type ContactId, Contact, ExtendedContact } from "schema/Contact" // These will be replaced with contact information that people can edit @@ -71,3 +71,8 @@ export const contacts = [ id: contact.userName as ContactId, }), ) + +/** for testing purposes */ +export const fakeExtendedContacts = contacts.map( + contact => new ExtendedContact({ ...contact, isSelf: false, isMember: true, isAdmin: true }), +) diff --git a/app/data/projects.ts b/app/data/projects.ts index f5a94c3c..54fd7061 100644 --- a/app/data/projects.ts +++ b/app/data/projects.ts @@ -3,72 +3,72 @@ import { Project } from "schema/Project" import tailwindColors from "tailwindcss/colors" const projectList = ` -Business: Contracts yes Contract negotiation & other back-and-forth -Business: Marketing Work on DevResults.com, writing blogs, creating materials, etc. -Business: Outreach sometimes Conferences, coffee, donuts, cold calls, etc -Business: Proposals yes Writing/editing proposals for specific prospects -DevOps: Azure migration -DevOps: CI configuration -DevOps: General Non-project-oriented DevOps work: server/VM/database configuration, etc. -Feature: Activity-Specific Stuff -Feature: Ag Grid conversions -Feature: API -Feature: Baselines -Feature: ContentObject Validation -Feature: Count First/Last -Feature: Count Unique Per Reporting Period -Feature: Dashboard -Feature: Data Tables -Feature: DevIndicators -Feature: Diagnostics -Feature: Dropbox integration -Feature: EditGrid -Feature: Enterprise -Feature: Frameworks Index 2022-S01 etc -Feature: GDPR -Feature: Google Drive integration -Feature: Ground Truth -Feature: IATI -Feature: Indexify -Feature: Instance Export -Feature: Internal Tooling Includes instance bootstrapper features, DevResults CLI, etc. -Feature: Localization Includes managing LanguageStrings, getting translations, etc. -Feature: Matrix -Feature: Metadata visualization -Feature: Notifications -Feature: Partner Permissions 2022 sprint 8 and associated work -Feature: PowerBI -Feature: Project X -Feature: Pseudonyms -Feature: Public Site -Feature: RDTs -Feature: Self-serve -Feature: Single Sign-On , and supporting clients -Feature: Standard RP Picker -Feature: Survey123 -Feature: SurveyCTO -Feature: Too small to name But you can name it in the comments! -Feature: WorldAdminDivisions update +Business:Contracts yes Contract negotiation & other back-and-forth +Business:Marketing Work on DevResults.com, writing blogs, creating materials, etc. +Business:Outreach sometimes Conferences, coffee, donuts, cold calls, etc +Business:Proposals yes Writing/editing proposals for specific prospects +DevOps:Azure migration +DevOps:CI configuration +DevOps:General Non-project-oriented DevOps work:server/VM/database configuration, etc. +Feature:Activity-Specific Stuff +Feature:Ag Grid conversions +Feature:API +Feature:Baselines +Feature:ContentObject Validation +Feature:Count First-Last +Feature:Count Unique Per Reporting Period +Feature:Dashboard +Feature:Data Tables +Feature:DevIndicators +Feature:Diagnostics +Feature:Dropbox integration +Feature:EditGrid +Feature:Enterprise +Feature:Frameworks Index 2022-S01 etc +Feature:GDPR +Feature:Google Drive integration +Feature:Ground Truth +Feature:IATI +Feature:Indexify +Feature:Instance Export +Feature:Internal Tooling Includes instance bootstrapper features, DevResults CLI, etc. +Feature:Localization Includes managing LanguageStrings, getting translations, etc. +Feature:Matrix +Feature:Metadata visualization +Feature:Notifications +Feature:Partner Permissions 2022 sprint 8 and associated work +Feature:PowerBI +Feature:Project X +Feature:Pseudonyms +Feature:Public Site +Feature:RDTs +Feature:Self-serve +Feature:Single Sign-On , and supporting clients +Feature:Standard RP Picker +Feature:Survey123 +Feature:SurveyCTO +Feature:Too small to name But you can name it in the comments! +Feature:WorldAdminDivisions update Out Overhead General meetings, company process stuff, finance stuff, HR, onboarding, learning… Security All things security -Support: API mostly Any help provided to helping users access and use the API -Support: External tooling Providing support for "other tools" outside of DevResults (e.g. PowerBI) -Support: Ongoing mostly Includes engineering support to individual clients (but not bug fixing) -Support: Scaling training & help Videos, help materials, etc. -Support: Setup yes Everything before the instance goes live -Support: Training sometimes Training dedicated to one specific client -Tech wealth: Bug fixin Probably mostly on call. Note feature in comments if it's a major feature fix. -Tech wealth: Other Catch-all for general improvements to the codebase -Tech wealth: Optimization Work to improve performance within the app -Tech wealth: TypeScript -Tech wealth: Webpack +Support:API mostly Any help provided to helping users access and use the API +Support:External tooling Providing support for "other tools" outside of DevResults (e.g. PowerBI) +Support:Ongoing mostly Includes engineering support to individual clients (but not bug fixing) +Support:Scaling training & help Videos, help materials, etc. +Support:Setup yes Everything before the instance goes live +Support:Training sometimes Training dedicated to one specific client +Tech wealth:Bug fixin Probably mostly on call. Note feature in comments if it's a major feature fix. +Tech wealth:Other Catch-all for general improvements to the codebase +Tech wealth:Optimization Work to improve performance within the app +Tech wealth:TypeScript +Tech wealth:Webpack Thought Leadership ` .trim() .split("\n") .map(line => { const [fullCode, requiresClient, description] = line.trim().split("\t") - const [code, subCode] = fullCode.split(/:\s+/).map(s => s.trim().replaceAll(" ", "-")) + const [code, subCode] = fullCode.split(/:\s*/).map(s => s.trim().replaceAll(" ", "-")) return { code, subCode, diff --git a/app/hooks/useRedirect.tsx b/app/hooks/useRedirect.tsx index cf7451e5..e65c8ae4 100644 --- a/app/hooks/useRedirect.tsx +++ b/app/hooks/useRedirect.tsx @@ -3,10 +3,11 @@ import { useLocation, useNavigate } from "@remix-run/react" import { useEffect } from "react" -export function useRedirect({ from, to }: Params) { +export function useRedirect({ from, to, condition = true }: Params) { const { pathname, state } = useLocation() const navigate = useNavigate() useEffect(() => { + if (!condition) return if (typeof from === "string" && pathname === from) { console.log(`redirecting from ${from} to ${to}`) navigate(to, { state }) @@ -21,4 +22,5 @@ export function useRedirect({ from, to }: Params) { type Params = { from: string | RegExp to: string + condition?: boolean } diff --git a/app/hooks/useRootDocument.tsx b/app/hooks/useRootDocument.tsx index 6b62268d..bf6dd194 100644 --- a/app/hooks/useRootDocument.tsx +++ b/app/hooks/useRootDocument.tsx @@ -15,14 +15,13 @@ export function useRootDocument< assert(rootDocumentId, "useRootDocument should only be used within an AuthContextProvider") const [rootDocument, changeFn] = useDocument(rootDocumentId) - const rootDocumentIsDefined = Boolean(rootDocument) const decoded = useMemo( () => rootDocument ? ((decode ? Root.decode(rootDocument) : rootDocument) as ReturnType) : undefined, - [rootDocumentIsDefined, decode], + [rootDocument, decode], ) return [decoded, changeFn] as const diff --git a/app/hooks/useSelectedYear.tsx b/app/hooks/useSelectedYear.tsx new file mode 100644 index 00000000..0074a1ed --- /dev/null +++ b/app/hooks/useSelectedYear.tsx @@ -0,0 +1,8 @@ +import { useParams } from "react-router-dom" +import { getCurrentYear } from "lib/getCurrentYear" +import { yearFromString } from "lib/yearFromString" + +export function useSelectedYear() { + const { year } = useParams() + return yearFromString(year) ?? getCurrentYear() +} diff --git a/app/index.css b/app/index.css index 747d27b5..96273a0e 100644 --- a/app/index.css +++ b/app/index.css @@ -24,7 +24,7 @@ h3 { .done-entry { @apply block w-full resize-none overflow-hidden break-words text-sm font-normal leading-tight; - @apply focus:outline-none; + @apply bg-transparent; } /* Menus */ diff --git a/app/lib/Effect.ts b/app/lib/Effect.ts index 37087d7b..f0b8f7d9 100644 --- a/app/lib/Effect.ts +++ b/app/lib/Effect.ts @@ -3,6 +3,17 @@ import { Effect } from "effect" export { Schema as S, ParseResult } from "@effect/schema" -export { Console, Context, Data, Effect as E, Either, Option, pipe, Types } from "effect" +export { + Console, + Clock, + Context, + Data, + Effect as E, + Either, + Option, + pipe, + Types, + Array, +} from "effect" export const $ = Effect.runSync diff --git a/app/lib/TimeEntryReport.ts b/app/lib/TimeEntryReport.ts deleted file mode 100644 index 8bca2e16..00000000 --- a/app/lib/TimeEntryReport.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { type LocalDate } from "@js-joda/core" -import { type TimeEntry } from "schema/TimeEntry" -import { TimeEntryView } from "./TimeEntryView" -import { isCompleteWeek } from "./isCompleteWeek" - -/** - * Creates a data structure that can be used render a report summarizing hours data by contact and week. - * - * Example report: - *``` - * 5 Complete ✅ ✅ ✅ ✅ ✅ - * Weeks - * Jan Feb - * 7 14 21 28 4 11 - * 6 Aasit | 40 40 40 40 40 40 - * 5 Herb | 40 40 40 40 40 24 - * ____________________________________________ - * Completed | 2 2 2 2 2 1 - * Weeks - * ``` - */ -export class TimeEntryReport { - /** Total number of weeks where all contacts completed their data */ - completeWeeks = 0 - - /** Ordered list of LocalDate representing the start of each week */ - weeks: LocalDate[] = [] - - /** List of contact week summaries */ - contactData: ContactSummary[] = [] - - constructor(entries: TimeEntry[]) { - const allEntriesView = new TimeEntryView(entries) - this.weeks = allEntriesView.getSundays() - - this.contactData = allEntriesView - .getContacts() - .map(contactId => ({ contactId, averageMins: 0, totalMins: 0, completedWeeks: 0, weeks: {} })) - - // calculate contact data for each week - for (const sunday of this.weeks) { - const weekView = allEntriesView.byWeek(sunday) - let isCompletedWeek = true - - for (const contactData of this.contactData) { - const contactWeekView = weekView.byContact(contactData.contactId) - const totalMins = Math.round( - contactWeekView.entries.reduce((total, entry) => total + (entry?.duration ?? 0), 0), - ) - - contactData.weeks[sunday.toString()] = totalMins - contactData.totalMins += totalMins - contactData.averageMins = Math.round( - contactData.totalMins / Object.keys(contactData.weeks).length, - ) - if (isCompleteWeek(totalMins)) contactData.completedWeeks++ - else isCompletedWeek = false - } - - if (isCompletedWeek) this.completeWeeks++ - } - } - - /** Returns a summary of the week containing information about contact participation and overall completion */ - getWeek(sundayDate: string) { - if (!this.weeks.some(d => d.toString() === sundayDate)) - throw new Error(`Date not found: ${sundayDate}`) - - const week: WeekSummary = { - totalMins: 0, - contactMins: {}, - isCompleted: false, - completedContactWeeks: 0, - } - - for (const contactData of this.contactData) { - const contactMins = contactData.weeks[sundayDate] - if (contactMins !== undefined) { - week.totalMins += contactMins - if (isCompleteWeek(contactMins)) week.completedContactWeeks++ - week.contactMins[contactData.contactId] = contactMins - } - } - - week.isCompleted = week.completedContactWeeks === Object.keys(week.contactMins).length - - return week - } -} - -type ContactSummary = { - contactId: string - completedWeeks: number - weeks: Record - averageMins: number - totalMins: number -} - -type WeekSummary = { - totalMins: number - contactMins: Record - completedContactWeeks: number - isCompleted: boolean -} diff --git a/app/lib/TimeEntryView.ts b/app/lib/TimeEntryView.ts deleted file mode 100644 index b9240b14..00000000 --- a/app/lib/TimeEntryView.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { type LocalDate } from "@js-joda/core" -import { getSunday } from "lib/getSunday" -import type { TimeEntry } from "schema/TimeEntry" - -/** A fluent-interface view over an array of {@link TimeEntry} records that provides helpful filtering. - * @example - * // returns entries for the given week - * const weekEntries = new TimeEntryView(data).byWeek("2024-04-07").entries - */ -export class TimeEntryView { - /** Create a view - * @param entries - The records to view - */ - constructor(public readonly entries: TimeEntry[] = []) {} - - /** Returns a new `TimeEntryView` containing the current entries for the given user - * @param contactId The ID of the user whose entries should be returned - */ - byContact(contactId: string) { - return new TimeEntryView(this.entries.filter(d => d.contactId === contactId)) - } - - /** Returns a new `TimeEntryView` containing the current entries within the given week - * @param sundayDate A LocalDate the Sunday within the desired week - */ - byWeek(sundayDate: LocalDate) { - return new TimeEntryView(this.entries.filter(d => getSunday(d.date).equals(sundayDate))) - } - - /** Returns a new `TimeEntryView` containing the current entries within the given year - * @param userId The ID of the user whose entries should be returned - */ - byYear(year: number) { - return new TimeEntryView(this.entries.filter(d => d.date.year() === year)) - } - - /** Returns a sorted list of distinct Sundays within the current entries in `LocalDate` format */ - getSundays() { - const { minDate, maxDate } = getDateRange(this.entries) - const firstSunday = getSunday(minDate) - const lastSunday = getSunday(maxDate) - const sundays = [] - for ( - let sunday = firstSunday; - sunday.isBefore(lastSunday) || sunday.equals(lastSunday); - sunday = sunday.plusDays(7) - ) { - sundays.push(sunday) - } - - return sundays - } - - /** Returns a list of distinct userIds within the current entries */ - getContacts() { - return [...new Set(this.entries.map(d => d.contactId))] - } -} - -/** Returns the minimum and maximum dates within an array of {@link TimeEntry} records */ -const getDateRange = (entries: TimeEntry[]) => - entries.reduce<{ minDate?: LocalDate; maxDate?: LocalDate }>( - ({ minDate, maxDate }, { date }) => ({ - minDate: !minDate || date.isBefore(minDate) ? date : minDate, - maxDate: !maxDate || date.isAfter(maxDate) ? date : maxDate, - }), - {}, - ) diff --git a/app/lib/asPercentage.tsx b/app/lib/asPercentage.tsx new file mode 100644 index 00000000..7b2a6a77 --- /dev/null +++ b/app/lib/asPercentage.tsx @@ -0,0 +1 @@ +export const asPercentage = (num: number, total: number) => `${Math.round((num / total) * 100)}%` diff --git a/app/lib/by.ts b/app/lib/by.ts index ce912213..77291d69 100644 --- a/app/lib/by.ts +++ b/app/lib/by.ts @@ -6,13 +6,9 @@ * ``` */ export const by = - , K extends keyof T>(key: K) => + (key: K) => (a: T, b: T) => { - const aVal = a[key].toString() ?? "" - const bVal = b[key].toString() ?? "" + const aVal = String(a[key]) + const bVal = String(b[key]) return aVal.localeCompare(bVal) } - -type ConvertibleToString = { - toString(): string -} diff --git a/app/lib/formatDateRange.ts b/app/lib/formatDateRange.ts index 1bbb7baf..81d318e6 100644 --- a/app/lib/formatDateRange.ts +++ b/app/lib/formatDateRange.ts @@ -1,9 +1,14 @@ import { type LocalDate } from "@js-joda/core" import { formatDate } from "./formatDate" -export const formatDateRange = (start: LocalDate, end: LocalDate) => { - const m1 = formatDate(start, "MMMM") - const m2 = formatDate(end, "MMMM") +export const formatDateRange = ( + start: LocalDate, + end: LocalDate, + { includeYear = true, monthFormat = "long" }: Options = {}, +) => { + const monthPattern = monthFormat === "short" ? "MMM" : "MMMM" + const m1 = formatDate(start, monthPattern) + const m2 = formatDate(end, monthPattern) const d1 = formatDate(start, "d") const d2 = formatDate(end, "d") const y1 = formatDate(start, "yyyy") @@ -13,8 +18,13 @@ export const formatDateRange = (start: LocalDate, end: LocalDate) => { if (y1 !== y2) return `${m1} ${d1}, ${y1} – ${m2} ${d2}, ${y2}` // straddling two months: January 29 – February 4, 2023 - if (m1 !== m2) return `${m1} ${d1} – ${m2} ${d2}, ${y2}` + if (m1 !== m2) return `${m1} ${d1} – ${m2} ${d2}${includeYear ? `, ${y2}` : ""}` // same month: January 1 – 7, 2023 - return `${m1} ${d1} – ${d2}, ${y2}` + return `${m1} ${d1} – ${d2}${includeYear ? `, ${y2}` : ""}` +} + +export type Options = { + includeYear?: boolean + monthFormat?: "short" | "long" } diff --git a/app/lib/generateDones.ts b/app/lib/generateDones.ts index 2a282b3d..41a56159 100644 --- a/app/lib/generateDones.ts +++ b/app/lib/generateDones.ts @@ -2,7 +2,7 @@ import type { LocalDate } from "@js-joda/core" import { dummyDones } from "data/dummyDones" import { isWeekend } from "lib/isWeekend" import { randomElement } from "lib/randomElement" -import type { Contact, ContactId } from "schema/Contact" +import type { Contact } from "schema/Contact" import { DoneEntry } from "schema/DoneEntry" export const generateDones = ({ today, weeks, productivity, enthusiasm, contacts }: params) => { @@ -18,7 +18,7 @@ export const generateDones = ({ today, weeks, productivity, enthusiasm, contacts const { id } = randomElement(contacts) const date = getRandomWorkday(today, weeks) const content = randomElement(dummyDones) - const likes: ContactId[] = contacts.map(u => u.id).filter(() => Math.random() < enthusiasm) + const likes: Contact[] = contacts.filter(() => Math.random() < enthusiasm) result.push(new DoneEntry({ content, date, contactId: id, likes })) } diff --git a/app/lib/generateTimeEntries.ts b/app/lib/generateTimeEntries.ts index a8cbceaf..6f3c2ae5 100644 --- a/app/lib/generateTimeEntries.ts +++ b/app/lib/generateTimeEntries.ts @@ -2,7 +2,7 @@ import { makeRandom } from "@herbcaudill/random" import { type LocalDate } from "@js-joda/core" import { dummyDones } from "data/dummyDones" import type { Client } from "schema/Client" -import type { Contact } from "schema/Contact" +import type { Contact, ContactId } from "schema/Contact" import { reconstructInput, type Project } from "schema/Project" import { TimeEntry } from "schema/TimeEntry" import { getWorkDays } from "./getWorkDays" @@ -13,69 +13,98 @@ export function generateTimeEntries({ clients, startDate, weekCount, + procrastinators = [], + omit = [], seed = "1234", }: Inputs) { const random = makeRandom(seed) - const OUT = projects.find(d => d.code.toLowerCase() === "out")! - const timeEntries: TimeEntry[] = [] + // Assign a timekeeping style to each contact + const contactStyles = Object.fromEntries( + contacts.map(c => { + const procrastinates = procrastinators.includes(c.firstName) || random.probability(0.1) + const weeksDelay = procrastinates ? random.integer(2, 4) : random.integer(0, 2) + const gapProbability = procrastinates ? random.decimal(0.1, 0.3) : 0 + return [c.id, { weeksDelay, gapProbability }] + }), + ) + + // Group workdays into weeks const workDays = getWorkDays(weekCount, startDate) + const weeks = workDays.reduce((acc, date, i) => { + const weekNum = Math.floor(i / 5) + acc[weekNum] ||= [] + acc[weekNum].push(date) + return acc + }, []) - const contactIds = contacts.map(c => c.id) + const dayOff = (contactId: ContactId, date: LocalDate) => { + const duration = 60 * 8 + return [ + new TimeEntry({ + contactId, + date, + project: OUT, + duration, + input: `Out ${duration}mins`, + }), + ] + } - let percentComplete = 0 - let dayIndex = 0 - for (const date of workDays) { - const currentPercentComplete = Math.floor((dayIndex / workDays.length) * 10) * 10 - if (currentPercentComplete > percentComplete) percentComplete = currentPercentComplete + const normalDay = (contactId: ContactId, date: LocalDate) => { + const todaysTotal = random.integer(5.5, 8.5) * 60 // 5.5 - 8.5 hrs + let totalDuration = 0 - // generate hrs of timeEntries for each contact - for (const contactId of contactIds) { - if (random.probability(0.15)) { - // take the day off :) - const duration = 60 * 8 - timeEntries.push( - new TimeEntry({ - contactId, - date, - project: OUT, - duration, - input: `Out ${duration}mins`, - }), - ) - } else { - const todaysTotal = random.integer(28, 36) * 15 // between 7 and 9 hours - let totalDuration = 0 - while (totalDuration < todaysTotal) { - const duration = Math.min(random.integer(1, 32) * 15, todaysTotal - totalDuration) - 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({ - durationInHours: duration / 60, - project: project.fullCode, - client: maybeClient.client?.code, - description, - }) - timeEntries.push( - new TimeEntry({ - contactId, - date, - project, - ...maybeClient, - duration, - input, - description, - }), - ) - totalDuration += duration - } - } + const newEntries: TimeEntry[] = [] + + while (totalDuration < todaysTotal) { + const duration = Math.min(random.integer(1, 32) * 15, todaysTotal - totalDuration) + 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({ + durationInHours: duration / 60, + project: project.fullCode, + client: maybeClient.client?.code, + description, + }) + newEntries.push( + new TimeEntry({ + contactId, + date, + project, + ...maybeClient, + duration, + input, + description, + }), + ) + totalDuration += duration } - dayIndex++ + return newEntries + } + + // For each contact, generate entries based on their style + for (const contact of contacts) { + if (omit.includes(contact.firstName)) continue + + const { weeksDelay, gapProbability } = contactStyles[contact.id] + + for (const week of weeks) { + const procrastinating = weeks.indexOf(week) > weeks.length - weeksDelay // procrastinators will be missing recent weeks + const recencyFalloff = 2 ** (weeks.indexOf(week) / weeks.length) + const skipWeek = random.probability(gapProbability * recencyFalloff) + if (skipWeek || procrastinating) continue + + for (const date of week) { + const isDayOff = random.probability(0.15) + const newEntries = isDayOff ? dayOff(contact.id, date) : normalDay(contact.id, date) + timeEntries.push(...newEntries) + } + } } return timeEntries @@ -87,5 +116,7 @@ type Inputs = { clients: Client[] startDate: LocalDate weekCount: number + procrastinators?: string[] + omit?: string[] seed?: string } diff --git a/app/lib/getCurrentYear.tsx b/app/lib/getCurrentYear.tsx new file mode 100644 index 00000000..160cff94 --- /dev/null +++ b/app/lib/getCurrentYear.tsx @@ -0,0 +1 @@ +export const getCurrentYear = () => new Date().getFullYear() diff --git a/app/lib/getSundaysForYear.tsx b/app/lib/getSundaysForYear.tsx new file mode 100644 index 00000000..5ba26f49 --- /dev/null +++ b/app/lib/getSundaysForYear.tsx @@ -0,0 +1,13 @@ +import { LocalDate, TemporalAdjusters, DayOfWeek } from "@js-joda/core" + +// HELPERS +export const getSundaysForYear = (year: number) => { + const firstSunday = LocalDate.of(year, 1, 1).with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) + + const sundays = [] + for (let d = firstSunday; d.year() === year; d = d.plusWeeks(1)) { + sundays.push(d) + } + + return sundays +} diff --git a/app/lib/isNumeric.tsx b/app/lib/isNumeric.tsx new file mode 100644 index 00000000..72626eac --- /dev/null +++ b/app/lib/isNumeric.tsx @@ -0,0 +1 @@ +export const isNumeric = (s: string) => /^\d+$/.test(s) diff --git a/app/lib/likesDescription.ts b/app/lib/likesDescription.ts index 2ff2ab1c..7a61cb44 100644 --- a/app/lib/likesDescription.ts +++ b/app/lib/likesDescription.ts @@ -1,28 +1,28 @@ -import { type ContactId } from "schema/Contact" +import { type Contact } from "schema/Contact" -export function likesDescription(likes: string[], contactId: ContactId) { +export function likesDescription(likes: Contact[], self: Contact) { const numLikes = likes.length - const sortedLikes = [...likes].sort() - const index = sortedLikes.indexOf(contactId) + const sortedNames = likes.map(({ firstName }) => firstName).sort() + const index = sortedNames.indexOf(self.firstName) if (index >= 0) { - sortedLikes.splice(index, 1) - sortedLikes.push("you") + sortedNames.splice(index, 1) + sortedNames.push("you") } let title = null switch (numLikes) { case 1: { - title = sortedLikes[0] + title = sortedNames[0] break } case 2: { - title = sortedLikes.join(" and ") + title = sortedNames.join(" and ") break } default: { - title = `${sortedLikes.slice(0, numLikes - 1).join(", ")}, and ${sortedLikes[numLikes - 1]}` + title = `${sortedNames.slice(0, numLikes - 1).join(", ")}, and ${sortedNames[numLikes - 1]}` break } } diff --git a/app/lib/plural.tsx b/app/lib/plural.tsx new file mode 100644 index 00000000..2c16298a --- /dev/null +++ b/app/lib/plural.tsx @@ -0,0 +1 @@ +export const plural = (num: number, word: string) => (num === 1 ? word : `${word}s`) diff --git a/app/lib/rankByScore.ts b/app/lib/rankByScore.ts new file mode 100644 index 00000000..4147e724 --- /dev/null +++ b/app/lib/rankByScore.ts @@ -0,0 +1,15 @@ +import { Order } from "effect" +import type { NonEmptyArray } from "effect/Array" +import { Array as A, pipe } from "lib/Effect" + +/** Given a list of scores associated with IDs, returns a map of rank to ids, where 0 is the lowest rank. */ +export const rankByScore = (items: Item[]) => { + const itemsByRank = pipe( + items as NonEmptyArray, + A.sortWith(item => item.score, Order.number), + A.groupWith((a, b) => a.score === b.score), + ) + return new Map(itemsByRank.map((items, index) => [index, items.map(item => item.id)])) +} + +type Item = { id: string; score: number } diff --git a/app/lib/sum.tsx b/app/lib/sum.tsx new file mode 100644 index 00000000..af7106e0 --- /dev/null +++ b/app/lib/sum.tsx @@ -0,0 +1 @@ +export const sum = (arr: number[]) => arr.reduce((acc, n) => acc + n, 0) diff --git a/app/lib/test/TimeEntryReport.test.ts b/app/lib/test/TimeEntryReport.test.ts deleted file mode 100644 index b123b640..00000000 --- a/app/lib/test/TimeEntryReport.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { LocalDate } from "@js-joda/core" -import { clients } from "data/clients" -import { contacts } from "data/contacts" -import { projects } from "data/projects" -import { describe, expect, test } from "vitest" -import { TimeEntryReport } from "../TimeEntryReport" -import { generateTimeEntries } from "../generateTimeEntries" - -const data6wks = generateTimeEntries({ - clients, - contacts, - projects, - startDate: LocalDate.parse("2024-07-09"), - weekCount: 6, - seed: "TimeEntryReport.test", -}) - -describe("TimeEntryReport", () => { - test("builds a correct report", () => { - const report = new TimeEntryReport(data6wks) - - expect(report.weeks).toHaveLength(6) - expect(report.completeWeeks).toEqual(6) - expect(report.contactData).toHaveLength(10) - - const herbData = report.contactData.find(d => d.contactId === "herb") - expect(herbData).toMatchInlineSnapshot(` - { - "averageMins": 2378, - "completedWeeks": 6, - "contactId": "herb", - "totalMins": 14265, - "weeks": { - "2024-07-07": 2490, - "2024-07-14": 2370, - "2024-07-21": 2205, - "2024-07-28": 2370, - "2024-08-04": 2460, - "2024-08-11": 2370, - }, - } - `) - }) - - test("getWeek returns correct data", () => { - const report = new TimeEntryReport(data6wks) - const actual = report.getWeek("2024-07-14") - - expect(actual).toMatchInlineSnapshot(` - { - "completedContactWeeks": 10, - "contactMins": { - "aasit": 2370, - "brent": 2415, - "colleen": 2310, - "fred": 2355, - "herb": 2370, - "leslie": 2370, - "nathan": 2280, - "reid": 2355, - "ritika": 2385, - "shane": 2325, - }, - "isCompleted": true, - "totalMins": 23535, - } - `) - }) -}) diff --git a/app/lib/test/TimeEntryView.benchmark.ts b/app/lib/test/TimeEntryView.benchmark.ts deleted file mode 100644 index 1658a5e4..00000000 --- a/app/lib/test/TimeEntryView.benchmark.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { LocalDate } from "@js-joda/core" -import { clients } from "data/clients" -import { contacts } from "data/contacts" -import { projects } from "data/projects" -import type { TimeEntry } from "schema/TimeEntry" -import { bench, describe } from "vitest" -import { TimeEntryView } from "../TimeEntryView" -import { generateTimeEntries } from "../generateTimeEntries" - -const generateData = (years: number) => - generateTimeEntries({ - clients, - contacts: contacts.slice(0, 5), - projects, - startDate: LocalDate.now(), - weekCount: 52 * years, - seed: "TimeEntryView.benchmark", - }) - -const data1year = generateData(1) -const data5year = generateData(5) -const data10year = generateData(10) -const data20year = generateData(20) - -describe("getSundays", () => { - const doWork = (data: TimeEntry[]) => { - const view = new TimeEntryView(data) - const _sundays = view.getSundays() - } - - bench("1 year", async () => doWork(data1year)) - bench("5 years", async () => doWork(data5year)) - bench("10 years", async () => doWork(data10year)) - bench("20 years", async () => doWork(data20year)) -}) - -describe("byWeek", () => { - const doWork = (data: TimeEntry[]) => { - const view = new TimeEntryView(data) - const sundays = view.getSundays() - const _week1 = view.byWeek(sundays[0]) - } - - bench("1 year", async () => doWork(data1year)) - bench("5 years", async () => doWork(data5year)) - bench("10 years", async () => doWork(data10year)) - bench("20 years", async () => doWork(data20year)) -}) - -describe("fluent queries", () => { - const doWork = (data: TimeEntry[]) => { - const view = new TimeEntryView(data) - const sundays = view.getSundays() - - const week1 = view.byWeek(sundays[0]) - const _herbWeek1 = week1.byContact("herb").entries - const _leslieWeek1 = week1.byContact("leslie").entries - const week2 = view.byWeek(sundays[1]) - const _herbWeek2 = week2.byContact("herb").entries - const _leslieWeek2 = week2.byContact("leslie").entries - } - - bench("1 year", async () => doWork(data1year)) - bench("5 years", async () => doWork(data5year)) - bench("10 years", async () => doWork(data10year)) - bench("20 years", async () => doWork(data20year)) -}) diff --git a/app/lib/test/TimeEntryView.test.ts b/app/lib/test/TimeEntryView.test.ts deleted file mode 100644 index 10eb4a61..00000000 --- a/app/lib/test/TimeEntryView.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { LocalDate } from "@js-joda/core" -import { clients } from "data/clients" -import { contacts } from "data/contacts" -import { projects } from "data/projects" -import { $, E, pipe } from "lib/Effect" -import { getSunday } from "lib/getSunday" -import { ClientCollection, ProvidedClients } from "schema/ClientCollection" -import { ProjectCollection, ProvidedProjects } from "schema/ProjectCollection" -import { type TimeEntryInput } from "schema/TimeEntry" -import { parseTimeEntry } from "schema/lib/parseTimeEntry" -import { describe, expect, test } from "vitest" -import { TimeEntryView } from "../TimeEntryView" -import { generateTimeEntries } from "../generateTimeEntries" -import { stripFields } from "./stripFields" - -const strip = stripFields(["id", "timestamp"]) -const TestProjects = new ProjectCollection(projects) -const TestClients = new ClientCollection(clients) - -const data6wks = generateTimeEntries({ - clients, - contacts: contacts.slice(0, 5), - projects, - startDate: LocalDate.parse("2024-07-09"), - weekCount: 6, - seed: "TimeEntryView.test", -}) - -describe("TimeEntryView", () => { - test("byWeek", () => { - const view = new TimeEntryView(data6wks) - const sunday = getSunday(LocalDate.parse("2024-07-14")) - const actual = view.byWeek(sunday) - - expect(actual.entries.length).toMatchInlineSnapshot(`56`) - - expect(actual.entries.slice(0, 2).map(d => strip(d))).toMatchInlineSnapshot(` - [ - { - "contactId": "herb", - "date": { - "_day": 15, - "_month": 7, - "_year": 2024, - }, - "description": "", - "duration": 390, - "input": "Feature:Metadata-visualization 390mins", - "project": { - "code": "Feature", - "color": "#f59e0b", - "description": undefined, - "requiresClient": false, - "subCode": "Metadata-visualization", - }, - }, - { - "contactId": "herb", - "date": { - "_day": 15, - "_month": 7, - "_year": 2024, - }, - "description": "", - "duration": 30, - "input": "Feature:DevIndicators 30mins", - "project": { - "code": "Feature", - "color": "#f59e0b", - "description": undefined, - "requiresClient": false, - "subCode": "DevIndicators", - }, - }, - ] - `) - }) - - test("byContact", () => { - const view = new TimeEntryView(data6wks) - const actual = view.byContact("herb") - expect(actual.entries.length).toMatchInlineSnapshot(`67`) - - expect(actual.entries.slice(0, 2).map(d => strip(d))).toMatchInlineSnapshot(` - [ - { - "contactId": "herb", - "date": { - "_day": 8, - "_month": 7, - "_year": 2024, - }, - "duration": 480, - "input": "Out 480mins", - "project": { - "code": "Out", - "color": "#eab308", - "description": undefined, - "requiresClient": false, - "subCode": undefined, - }, - }, - { - "contactId": "herb", - "date": { - "_day": 9, - "_month": 7, - "_year": 2024, - }, - "description": "", - "duration": 360, - "input": "Support:Ongoing 360mins", - "project": { - "code": "Support", - "color": "#10b981", - "description": "Includes engineering support to individual clients (but not bug fixing)", - "requiresClient": false, - "subCode": "Ongoing", - }, - }, - ] - `) - }) - - test("byYear", () => { - const sampleData = [ - { - contactId: "leslie", - date: LocalDate.parse("2023-05-17"), - input: "160min #Security", - }, - { - contactId: "leslie", - date: LocalDate.parse("2023-05-17"), - input: "160min #Support: Setup @aba", - }, - { - contactId: "leslie", - date: LocalDate.parse("2024-05-17"), - input: "160min #Support: Ongoing", - }, - { - contactId: "leslie", - date: LocalDate.parse("2024-05-17"), - input: "160min #Support: Ongoing", - }, - ].map(d => - pipe( - d as TimeEntryInput, - parseTimeEntry, - E.provideService(ProvidedProjects, TestProjects), - E.provideService(ProvidedClients, TestClients), - $, - ), - ) - const view = new TimeEntryView(sampleData) - const actual = view.byYear(2023) - - expect(actual.entries.map(d => strip(d))).toMatchInlineSnapshot(` - [ - { - "client": undefined, - "contactId": "leslie", - "date": { - "_day": 17, - "_month": 5, - "_year": 2023, - }, - "description": "", - "duration": 160, - "input": "160min #Security", - "project": { - "code": "Security", - "color": "#22c55e", - "description": "All things security", - "requiresClient": false, - "subCode": undefined, - }, - }, - { - "client": { - "code": "aba", - }, - "contactId": "leslie", - "date": { - "_day": 17, - "_month": 5, - "_year": 2023, - }, - "description": "", - "duration": 160, - "input": "160min #Support: Setup @aba", - "project": { - "code": "Support", - "color": "#10b981", - "description": "Everything before the instance goes live", - "requiresClient": true, - "subCode": "Setup", - }, - }, - ] - `) - }) - - test("getSundays", () => { - const view = new TimeEntryView(data6wks) - const actual = view.getSundays() - - expect(actual).toHaveLength(6) - expect(actual[0].toString()).toMatchInlineSnapshot(`"2024-07-07"`) - expect(actual[5].toString()).toMatchInlineSnapshot(`"2024-08-11"`) - }) - - test("getContacts", () => { - const view = new TimeEntryView(data6wks) - const actual = view.getContacts() - - expect(actual).toEqual(["herb", "shane", "brent", "leslie", "ritika"]) - }) - - test("fluent queries", () => { - const view = new TimeEntryView(data6wks) - const sundays = view.getSundays() - - const week1 = view.byWeek(sundays[0]) - const herbWeek1 = week1.byContact("herb").entries - const leslieWeek1 = week1.byContact("leslie").entries - - expect(herbWeek1.length).toMatchInlineSnapshot(`9`) - expect(leslieWeek1.length).toMatchInlineSnapshot(`15`) - - const week2 = view.byWeek(sundays[1]) - const herbWeek2 = week2.byContact("herb").entries - const leslieWeek2 = week2.byContact("leslie").entries - - expect(herbWeek2.length).toMatchInlineSnapshot(`11`) - expect(leslieWeek2.length).toMatchInlineSnapshot(`10`) - }) -}) diff --git a/app/lib/test/findAutocompleteQuery.test.ts b/app/lib/test/findAutocompleteQuery.test.ts new file mode 100644 index 00000000..7edde8f9 --- /dev/null +++ b/app/lib/test/findAutocompleteQuery.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest" +import { + findAutocompleteQuery, + type AutocompleteState, + type AutocompleteTrigger, +} from "ui/AutocompleteMenu" +import { assert } from "lib/assert" + +const autocompleteTriggers: AutocompleteTrigger[] = [ + { type: "PROJECT", trigger: "#" }, + { type: "CLIENT", trigger: "@" }, +] as const + +// the `|` character represents the cursor position +const cases: TestCase[] = [ + { + name: "@ trigger at end of word", + input: "#support @usaid|", + expected: { trigger: "@", query: "usaid", start: 9, end: 15 }, + }, + { + name: "# trigger at end of word", + input: "#support|", + expected: { trigger: "#", query: "support", start: 0, end: 8 }, + }, + { + name: "trigger in middle of word", + input: "#support @usa|id pizza", + expected: { trigger: "@", query: "usaid", start: 9, end: 15 }, + }, + { + name: "trigger at start of word", + input: "#support |@usaid pizza", + expected: { trigger: "@", query: "usaid", start: 9, end: 15 }, + }, + { + name: "cursor not in trigger word", + input: "he|llo @usaid", + expected: undefined, + }, + { + name: "empty string", + input: "|", + expected: undefined, + }, + { + name: "spaces around trigger word", + input: " @us|aid ", + expected: { trigger: "@", query: "usaid", start: 3, end: 9 }, + }, +] + +/** + * Takes a string containing a cursor marker `|` and returns the text without the cursor marker + * along with the position of the cursor + */ +const extractCursor = (text: string): { text: string; position: number } => { + const position = text.indexOf("|") + if (position === -1) throw new Error("No cursor marker found in test input") + return { + text: text.replace("|", ""), + position, + } +} + +describe("findAutocompleteTriggerAtCursor", () => { + test.each(cases)("$name", ({ input, expected }) => { + const { text, position } = extractCursor(input) + const result = findAutocompleteQuery(text, position, autocompleteTriggers) + if (expected === undefined) { + expect(result).toBeUndefined() + } else { + assert(result !== undefined) + const { trigger, query, start, end } = result + expect({ trigger, query, start, end }).toEqual(expected) + } + }) +}) + +type TestCase = { + name: string + input: string // includes `|` marker + expected: Pick | undefined +} diff --git a/app/lib/test/formatDateRange.test.ts b/app/lib/test/formatDateRange.test.ts index 194eae84..a06b805e 100644 --- a/app/lib/test/formatDateRange.test.ts +++ b/app/lib/test/formatDateRange.test.ts @@ -1,33 +1,91 @@ import { LocalDate } from "@js-joda/core" import { describe, expect, test } from "vitest" -import { formatDateRange } from "../formatDateRange" +import { formatDateRange, type Options } from "../formatDateRange" describe("formatDateRange", () => { - const testCase = (startOfWeek: string, endOfWeek: string, expected: string): void => { - const actual = formatDateRange(LocalDate.parse(startOfWeek), LocalDate.parse(endOfWeek)) - expect(actual).toEqual(expected) - } - - describe("Weeks within the same month", () => { - test.each([ - ["2023-01-01", "2023-01-07", "January 1 – 7, 2023"], - ["2023-01-08", "2023-01-14", "January 8 – 14, 2023"], - ["2023-01-15", "2023-01-21", "January 15 – 21, 2023"], - ])("%s to %s", testCase) + const testCase = + (options?: Options) => + (expected: string, startOfWeek: string, endOfWeek: string): void => { + const actual = formatDateRange( + LocalDate.parse(startOfWeek), + LocalDate.parse(endOfWeek), + options, + ) + expect(actual).toEqual(expected) + } + + describe("default options", () => { + describe("within the same month", () => { + test.each([ + ["January 1 – 7, 2023", "2023-01-01", "2023-01-07"], + ["January 8 – 14, 2023", "2023-01-08", "2023-01-14"], + ["January 15 – 21, 2023", "2023-01-15", "2023-01-21"], + ])("%s", testCase()) + }) + + describe("straddling two months", () => { + test.each([ + ["January 30 – February 5, 2022", "2022-01-30", "2022-02-05"], + ["January 29 – February 4, 2023", "2023-01-29", "2023-02-04"], + ["February 26 – March 4, 2023", "2023-02-26", "2023-03-04"], + ])("%s", testCase()) + }) + + describe("straddling two years", () => { + test.each([ + ["December 26, 2021 – January 1, 2022", "2021-12-26", "2022-01-01"], + ["December 31, 2023 – January 6, 2024", "2023-12-31", "2024-01-06"], + ])("%s", testCase()) + }) }) - describe("Weeks that straddle two months", () => { - test.each([ - ["2022-01-30", "2022-02-05", "January 30 – February 5, 2022"], - ["2023-01-29", "2023-02-04", "January 29 – February 4, 2023"], - ["2023-02-26", "2023-03-04", "February 26 – March 4, 2023"], - ])("%s to %s", testCase) + describe("short month", () => { + describe("within the same month", () => { + test.each([ + ["Jan 1 – 7, 2023", "2023-01-01", "2023-01-07"], + ["Jan 8 – 14, 2023", "2023-01-08", "2023-01-14"], + ["Jan 15 – 21, 2023", "2023-01-15", "2023-01-21"], + ])("%s", testCase({ monthFormat: "short" })) + }) + + describe("straddling two months", () => { + test.each([ + ["Jan 30 – Feb 5, 2022", "2022-01-30", "2022-02-05"], + ["Jan 29 – Feb 4, 2023", "2023-01-29", "2023-02-04"], + ["Feb 26 – Mar 4, 2023", "2023-02-26", "2023-03-04"], + ])("%s", testCase({ monthFormat: "short" })) + }) + + describe("straddling two years", () => { + test.each([ + ["Dec 26, 2021 – Jan 1, 2022", "2021-12-26", "2022-01-01"], + ["Dec 31, 2023 – Jan 6, 2024", "2023-12-31", "2024-01-06"], + ])("%s", testCase({ monthFormat: "short" })) + }) }) - describe("Weeks that straddle two years", () => { - test.each([ - ["2021-12-26", "2022-01-01", "December 26, 2021 – January 1, 2022"], - ["2023-12-31", "2024-01-06", "December 31, 2023 – January 6, 2024"], - ])("%s to %s", testCase) + describe("short month, no year", () => { + describe("within the same month", () => { + test.each([ + ["Jan 1 – 7", "2023-01-01", "2023-01-07"], + ["Jan 8 – 14", "2023-01-08", "2023-01-14"], + ["Jan 15 – 21", "2023-01-15", "2023-01-21"], + ])("%s", testCase({ monthFormat: "short", includeYear: false })) + }) + + describe("straddling two months", () => { + test.each([ + ["Jan 30 – Feb 5", "2022-01-30", "2022-02-05"], + ["Jan 29 – Feb 4", "2023-01-29", "2023-02-04"], + ["Feb 26 – Mar 4", "2023-02-26", "2023-03-04"], + ])("%s", testCase({ monthFormat: "short", includeYear: false })) + }) + + describe("straddling two years", () => { + test.each([ + ["Dec 26, 2021 – Jan 1, 2022", "2021-12-26", "2022-01-01"], + ["Dec 31, 2023 – Jan 6, 2024", "2023-12-31", "2024-01-06"], + ])("%s", testCase({ monthFormat: "short", includeYear: false })) + }) }) }) diff --git a/app/lib/test/generateTimeEntries.test.ts b/app/lib/test/generateTimeEntries.test.ts index 89e855dc..980d079e 100644 --- a/app/lib/test/generateTimeEntries.test.ts +++ b/app/lib/test/generateTimeEntries.test.ts @@ -23,7 +23,7 @@ describe("generateTimeEntries", () => { const totalHours = Math.ceil(entries.reduce((total, entry) => total + entry.duration, 0)) expect(totalHours).toBeGreaterThanOrEqual( 60 * // mins per hour - 7 * // hours per day (lower bound in generator) + 5.5 * // hours per day (lower bound in generator) 5 * // days per week 1 * // weeks 1, // contacts @@ -45,7 +45,7 @@ describe("generateTimeEntries", () => { const totalMins = Math.ceil(entries.reduce((total, entry) => total + entry.duration, 0)) expect(totalMins).toBeGreaterThanOrEqual( 60 * // mins per hour - 7 * // hours per day (lower bound in generator) + 5.5 * // hours per day (lower bound in generator) 5 * // days per week 6 * // weeks 5, // contacts @@ -67,7 +67,7 @@ describe("generateTimeEntries", () => { const totalMins = Math.ceil(entries.reduce((total, entry) => total + entry.duration, 0)) expect(totalMins).toBeGreaterThanOrEqual( 60 * // mins per hour - 7 * // hours per day (lower bound in generator) + 5.5 * // hours per day (lower bound in generator) 5 * // days per week 104 * // weeks 10, // contacts diff --git a/app/lib/test/likesDescription.test.ts b/app/lib/test/likesDescription.test.ts index fbb93e10..ea8547df 100644 --- a/app/lib/test/likesDescription.test.ts +++ b/app/lib/test/likesDescription.test.ts @@ -1,28 +1,32 @@ -import { type ContactId } from "schema/Contact" +import { contacts } from "data/contacts" +import { type Contact } from "schema/Contact" import { describe, expect, test } from "vitest" import { likesDescription } from "../likesDescription" +const [herb, shane, brent, leslie, ritika, aasit, reid, nathan, fred] = contacts + describe("likesDescription", () => { test("sorts names", () => { - expect(likes("b", "a")).toBe("a and b liked this") + expect(likes(shane, herb)).toBe("Herb and Shane liked this") }) - test("replaces userId with 'you'", () => { - expect(likes("joe")).toBe("you liked this") + test("replaces self's name with 'you'", () => { + expect(likes(fred)).toBe("you liked this") }) test("puts 'you' last", () => { - expect(likes("a", "b", "joe", "z")).toBe("a, b, z, and you liked this") + expect(likes(herb, shane, brent, leslie, fred)).toBe( + "Brent, Herb, Leslie, Shane, and you liked this", + ) }) test("does not elide anyone", () => { - expect(likes("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")).toBe( - "a, b, c, d, e, f, g, h, i, and j liked this", + expect(likes(herb, shane, brent, leslie, ritika, aasit, reid, nathan, fred)).toBe( + "Aasit, Brent, Herb, Leslie, Nathan, Reid, Ritika, Shane, and you liked this", ) }) - // curry the current user to make tests simpler - function likes(...likers: string[]) { - return likesDescription(likers, "joe" as ContactId) + function likes(...likers: Contact[]) { + return likesDescription(likers, fred) } }) diff --git a/app/lib/test/rankByScore.test.ts b/app/lib/test/rankByScore.test.ts new file mode 100644 index 00000000..bdec757b --- /dev/null +++ b/app/lib/test/rankByScore.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect } from "vitest" +import { rankByScore } from "../rankByScore" + +describe("rankByScore", () => { + test("no ties", () => { + const items = [ + { id: "a", score: 20 }, + { id: "b", score: 30 }, + { id: "c", score: 10 }, + ] + const result = rankByScore(items) + expect(result).toEqual( + new Map([ + [0, ["c"]], + [1, ["a"]], + [2, ["b"]], + ]), + ) + }) + + test("one tie", () => { + const items = [ + { id: "a", score: 10 }, + { id: "b", score: 20 }, + { id: "c", score: 10 }, + { id: "d", score: 30 }, + ] + const result = rankByScore(items) + expect(result).toEqual( + new Map([ + [0, ["a", "c"]], + [1, ["b"]], + [2, ["d"]], + ]), + ) + }) + + test("two ties", () => { + const items = [ + { id: "a", score: 10 }, + { id: "b", score: 20 }, + { id: "c", score: 10 }, + { id: "d", score: 20 }, + { id: "e", score: 30 }, + ] + const result = rankByScore(items) + expect(result).toEqual( + new Map([ + [0, ["a", "c"]], + [1, ["b", "d"]], + [2, ["e"]], + ]), + ) + }) +}) diff --git a/app/lib/yearFromString.tsx b/app/lib/yearFromString.tsx new file mode 100644 index 00000000..a89eb41a --- /dev/null +++ b/app/lib/yearFromString.tsx @@ -0,0 +1,7 @@ +import { isNumeric } from "./isNumeric" + +export const yearFromString = (s: string | undefined) => { + if (s === undefined) return undefined + if (!isNumeric(s)) return undefined + return Number.parseInt(s, 10) +} diff --git a/app/routes/$.tsx b/app/routes/$.tsx index 2486335d..f18028cc 100644 --- a/app/routes/$.tsx +++ b/app/routes/$.tsx @@ -1,6 +1,6 @@ import { useRedirect } from "hooks/useRedirect" -export default function Redirect() { +export default function GlobalRedirects() { useRedirect({ from: /^\/join\/(.+)/i, to: "/auth/setup/join/$1" }) useRedirect({ from: /^\/link\/(.+)/i, to: "/auth/setup/link/$1" }) } diff --git a/app/routes/_private+/_private.tsx b/app/routes/_private+/_private.tsx index eebe0a6f..fc6c4706 100644 --- a/app/routes/_private+/_private.tsx +++ b/app/routes/_private+/_private.tsx @@ -5,7 +5,7 @@ import { useTeam } from "hooks/useTeam" import { AppLayout } from "ui/layouts/AppLayout" import { Loading } from "ui/Loading" -export default function Private() { +export default function PrivateLayout() { return ( diff --git a/app/routes/_private+/devtools+/_devtools.tsx b/app/routes/_private+/devtools+/_devtools.tsx index e85c8c62..4ab17600 100644 --- a/app/routes/_private+/devtools+/_devtools.tsx +++ b/app/routes/_private+/devtools+/_devtools.tsx @@ -2,7 +2,7 @@ import { Outlet } from "react-router-dom" import { PageLayout } from "ui/layouts/PageLayout" import { SecondaryNav } from "ui/SecondaryNav" -export default function DevTools() { +export default function DevtoolsPage() { return ( { - const weekOptions = ["1", "2", "5", "10", "20", "50", "200"] + const weekOptions = ["1", "2", "5", "10", "20", "50", "100"] const [weeks, setWeeks] = useState(Number(weekOptions[2])) const [successMessage, setSuccessMessage] = useState(undefined) @@ -29,8 +29,10 @@ export const TimeEntryGenerator = ({ contacts, projects, clients, + procrastinators: ["Herb", "Aasit"], + omit: ["Colleen"], }) - for (const timeEntry of timeEntries) add(timeEntry) + add(timeEntries) setSuccessMessage(`Generated ${timeEntries.length} entries`) } @@ -64,5 +66,5 @@ type Props = { projects: Project[] clients: Client[] destroyAll(): void - add(done: TimeEntry): void + add(entries: TimeEntry[]): void } diff --git a/app/routes/_private+/devtools+/inspector+/_inspector.tsx b/app/routes/_private+/devtools+/inspector+/_inspector.tsx index d05c9455..b5a2db05 100644 --- a/app/routes/_private+/devtools+/inspector+/_inspector.tsx +++ b/app/routes/_private+/devtools+/inspector+/_inspector.tsx @@ -3,7 +3,7 @@ import { Tabs, TabsList, TabsContent, TabsTrigger } from "@ui/tabs" import { JsonView, defaultStyles } from "react-json-view-lite" import "react-json-view-lite/dist/index.css" -export default function Clients() { +export default function InspectorPage() { const [rootDoc] = useRootDocument({ decode: false }) if (!rootDoc) return null diff --git a/app/routes/_private+/dones+/$date.tsx b/app/routes/_private+/dones+/$date.tsx index d380d4c1..6daa3fda 100644 --- a/app/routes/_private+/dones+/$date.tsx +++ b/app/routes/_private+/dones+/$date.tsx @@ -1,4 +1,3 @@ -import { Outlet } from "@remix-run/react" import { useDatabase } from "hooks/useDatabase" import { useSelectedWeek } from "hooks/useSelectedWeek" import { useTeam } from "hooks/useTeam" @@ -7,7 +6,7 @@ import { PageLayout } from "ui/layouts/PageLayout" import { TeamDones } from "ui/TeamDones" import { WeekNav } from "ui/WeekNav" -export default function TeamDonesByDate() { +export default function Dones$DatePage() { const { doneEntries } = useDatabase() const { start } = useSelectedWeek() const { self, contacts } = useTeam() @@ -15,7 +14,15 @@ export default function TeamDonesByDate() { const dones = $(doneEntries.findBy("week", start)) return ( - }> + +

Dones

+ + + } + >
$(doneEntries.update({ id, likes }))} /> -
) diff --git a/app/routes/_private+/dones+/_dones.tsx b/app/routes/_private+/dones+/_dones.tsx index 0fc71710..6b79c96c 100644 --- a/app/routes/_private+/dones+/_dones.tsx +++ b/app/routes/_private+/dones+/_dones.tsx @@ -4,7 +4,7 @@ import { getSunday } from "lib/getSunday" const currentWeek = getSunday().toString() -export default function TeamDones() { +export default function DonesLayout() { useRedirect({ from: "/dones", to: `/dones/${currentWeek}` }) return } diff --git a/app/routes/_private+/hours+/$year.tsx b/app/routes/_private+/hours+/$year.tsx new file mode 100644 index 00000000..8e6334b2 --- /dev/null +++ b/app/routes/_private+/hours+/$year.tsx @@ -0,0 +1,38 @@ +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 { getCurrentYear } from "lib/getCurrentYear" +import { HoursReport } from "ui/HoursReport" +import { PageLayout } from "ui/layouts/PageLayout" +import { YearNav } from "ui/YearNav" + +export default function Hours$YearPage() { + const { contacts, timeEntries } = useDatabase() + const currentYear = getCurrentYear() + const { self } = useTeam() + const year = useSelectedYear() + + useRedirect({ from: "/hours", to: `/hours/${currentYear}`, condition: year > currentYear }) + + const years = A.dedupe($(timeEntries.all()).map(entry => entry.date.year())) + const minYear = Math.min(...years) + const maxYear = Math.max(...years) + + return ( + +

Hours

+ + + } + > +
+ +
+
+ ) +} diff --git a/app/routes/_private+/hours+/_hours.tsx b/app/routes/_private+/hours+/_hours.tsx index 42f86d8d..458160cd 100644 --- a/app/routes/_private+/hours+/_hours.tsx +++ b/app/routes/_private+/hours+/_hours.tsx @@ -1,10 +1,8 @@ -import { ComingSoon } from "ui/ComingSoon" +import { Outlet } from "@remix-run/react" +import { useRedirect } from "hooks/useRedirect" +import { getCurrentYear } from "lib/getCurrentYear" -/** This will be the hours completeness report */ -export default function Hours() { - return ( - - - - ) +export default function HoursLayout() { + useRedirect({ from: "/hours", to: `/hours/${getCurrentYear()}` }) + return } diff --git a/app/routes/_private+/myweek+/$date.tsx b/app/routes/_private+/myweek+/$date.tsx index c6f323e7..1c2bff43 100644 --- a/app/routes/_private+/myweek+/$date.tsx +++ b/app/routes/_private+/myweek+/$date.tsx @@ -7,7 +7,7 @@ import { PageLayout } from "ui/layouts/PageLayout" import { MyWeek } from "ui/MyWeek" import { WeekNav } from "ui/WeekNav" -export default function MyWeekByDate() { +export default function MyWeek$DatePage() { const { doneEntries, timeEntries, projects, clients } = useDatabase() const { start } = useSelectedWeek() const { self } = useTeam() @@ -15,17 +15,14 @@ export default function MyWeekByDate() { return ( -
- -
- - {/* show weekends checkbox */} +
+

My week

+
setShowWeekends(e === true)} /> -
+