diff --git a/dash/components/package.json b/dash/components/package.json index e2946c44..6385679f 100644 --- a/dash/components/package.json +++ b/dash/components/package.json @@ -25,6 +25,7 @@ "uuid": "9.0.0" }, "devDependencies": { + "@shared/datetime": "workspace:*", "@types/uuid": "8.3.4" } } diff --git a/dash/components/src/Users/Activity/CombinedUsersActivityFeed.tsx b/dash/components/src/Users/Activity/CombinedUsersActivityFeed.tsx index c879ef4f..60a7097b 100644 --- a/dash/components/src/Users/Activity/CombinedUsersActivityFeed.tsx +++ b/dash/components/src/Users/Activity/CombinedUsersActivityFeed.tsx @@ -4,7 +4,7 @@ import { Button } from '@shared/components'; import { posessive } from '@shared/string'; import type { ActivityFeedItem } from './UserActivityFeed'; import { FeedCaughtUp } from './UserActivityFeed'; -import { deleteableChunks } from './UserActivityFeed'; +import DeletableActivityChunks from './DeletableActivityChunks'; import FeedHeader from './FeedHeader'; import ReviewDayWrapper from './ReviewDayWrapper'; import UserActivityHeader from './UserActivityHeader'; @@ -57,7 +57,11 @@ const CombinedUsersActivityFeed: React.FC = ({ className="flex flex-col justify-center space-y-4 md:border md:border-slate-200 md:rounded-3xl md:pt-6 lg:pt-8 md:px-4 lg:px-8 md:pb-0 md:bg-white/50" > {userName} - {deleteableChunks(items, chunkSize, deleteItems)} + {items.length > 1 && (
+
+ ); + } + })} + +); + +export default DeletableActivityChunks; + +const Item: React.FC<{ + item: ActivityFeedItem; + deleteItem: () => unknown; +}> = ({ item, deleteItem }) => { + if (item.type === `Screenshot`) { + return ( + + ); + } else { + return ( + + ); + } +}; + +export type Chunkable = { + id: UUID; + type: 'Screenshot' | 'KeystrokeLine'; + duringSuspension: boolean; + date: ISODateString; +}; + +type ActivityRenderTask = + | { type: 'item'; item: T } + | { type: 'suspension_group'; items: T[] } + | { type: 'delete_btn'; ids: UUID[] }; + +// an extraction of core logic without rendering for testability +export function chunkedRenderTasks( + items: T[], + chunkSize: number, +): Array[]> { + const ids: UUID[] = []; + const chunkedTasks: Array[]> = []; + const numChunks = Math.ceil(items.length / chunkSize); + + for (let chunkIndex = 0; chunkIndex < numChunks; chunkIndex++) { + const tasks: ActivityRenderTask[] = []; + const chunkOffset = chunkIndex * chunkSize; + const chunkItems = items.slice(chunkOffset, chunkOffset + chunkSize); + let suspensionBuffer: T[] = []; + + for (let i = 0; i < chunkItems.length; i++) { + const item = chunkItems[i]; + if (!item) continue; + const isLastItem = chunkItems[chunkItems.length - 1]?.id === item.id; + const finishingSuspension = + (!item.duringSuspension && suspensionBuffer.length > 0) || + (isLastItem && item.duringSuspension); + + if (item.duringSuspension) { + suspensionBuffer.push(item); + } + + if (finishingSuspension) { + if (shouldBeMergedIntoSuspensionGroup(item, chunkItems, i)) { + item.duringSuspension = true; + suspensionBuffer.push(item); + } else { + tasks.push({ type: `suspension_group`, items: suspensionBuffer }); + suspensionBuffer = []; + } + } + + if (!item.duringSuspension) { + tasks.push({ type: `item`, item }); + } + + ids.push(item.id); + } + + if (chunkIndex < numChunks - 1) { + const toDelete = [...ids]; + tasks.push({ type: `delete_btn`, ids: toDelete }); + } + chunkedTasks.push(tasks); + } + + return chunkedTasks; +} + +// because of the way the macapp emits activity events +// we sometimes get one or two keystroke lines near the +// end of a suspension period marked as not during suspension. +// this helps fold those into a suspension group so we don't +// end up with one or two un-suspended keystroke lines +// in the middle of a bunch of suspended ones +function shouldBeMergedIntoSuspensionGroup( + item: Chunkable, + items: Chunkable[], + index: number, +): boolean { + if (item.type === `Screenshot`) return false; + const prev = items[index - 1]; + if (!prev) return false; + const prevDate = new Date(prev.date); + // if we can peek ahead a few and find a suspended item within 4 minutes + // while only skipping over keystroke lines, then we should merge + for (const peek of items.slice(index + 1, index + 5)) { + if (!peek.duringSuspension) continue; + if (peek.type === `KeystrokeLine`) return false; + const peekDate = new Date(peek.date); + const diff = prevDate.getTime() - peekDate.getTime(); + if (diff < 1000 * 60 * 4) { + return true; + } + } + return false; +} diff --git a/dash/components/src/Users/Activity/UserActivityFeed.tsx b/dash/components/src/Users/Activity/UserActivityFeed.tsx index 86d6f3ae..be283e7d 100644 --- a/dash/components/src/Users/Activity/UserActivityFeed.tsx +++ b/dash/components/src/Users/Activity/UserActivityFeed.tsx @@ -3,10 +3,9 @@ import { useNavigate } from 'react-router-dom'; import { Button } from '@shared/components'; import { UndoMainPadding } from '../../Chrome/Chrome'; import EmptyState from '../../EmptyState'; -import KeystrokesViewer from './KeystrokesViewer'; -import ScreenshotViewer from './ScreenshotViewer'; import FeedHeader from './FeedHeader'; import ReviewDayWrapper from './ReviewDayWrapper'; +import DeletableActivityChunks from './DeletableActivityChunks'; interface Screenshot { type: 'Screenshot'; @@ -51,7 +50,11 @@ const UserActivityFeed: React.FC = ({ {items.length > 0 ? ( - {deleteableChunks(items, chunkSize, deleteItems)} + - , - ); - } - } - return elements; -} - -function renderItem(item: ActivityFeedItem, deleteItem: () => unknown): JSX.Element { - if (item.type === `Screenshot`) { - return ( - - ); - } else { - return ( - - ); - } -} diff --git a/dash/components/src/Users/Activity/__tests__/deletable-chunks.spec.ts b/dash/components/src/Users/Activity/__tests__/deletable-chunks.spec.ts new file mode 100644 index 00000000..a3ea6553 --- /dev/null +++ b/dash/components/src/Users/Activity/__tests__/deletable-chunks.spec.ts @@ -0,0 +1,185 @@ +import { describe, expect, beforeEach, it } from 'vitest'; +import { time } from '@shared/datetime'; +import { chunkedRenderTasks, type Chunkable } from '../DeletableActivityChunks'; + +describe(`chunkedRenderTasks()`, () => { + beforeEach(resetId); + + it(`should group into chunks`, () => { + const chunks = chunkedRenderTasks( + [screenshot(), screenshot(), screenshot(), screenshot()], + 2, + ); + expect(simplify(chunks)).toMatchInlineSnapshot(` + [ + "item(Screenshot)", + "item(Screenshot)", + "delete_btn", + "item(Screenshot)", + "item(Screenshot)", + ] + `); + }); + + it(`should group into chunks with suspension groups`, () => { + const chunks = chunkedRenderTasks( + [ + screenshot({ duringSuspension: true }), + screenshot({ duringSuspension: true }), + screenshot(), + screenshot(), + ], + 2, + ); + expect(simplify(chunks)).toMatchInlineSnapshot(` + [ + "suspension_group(Screenshot, Screenshot)", + "delete_btn", + "item(Screenshot)", + "item(Screenshot)", + ] + `); + }); + + it(`handles suspensions across chunk boundaries`, () => { + const chunks = chunkedRenderTasks( + [ + keystroke({ duringSuspension: true }), + screenshot({ duringSuspension: true }), + screenshot({ duringSuspension: true }), + screenshot(), + ], + 2, + ); + expect(simplify(chunks)).toMatchInlineSnapshot(` + [ + "suspension_group(KeystrokeLine, Screenshot)", + "delete_btn", + "suspension_group(Screenshot)", + "item(Screenshot)", + ] + `); + }); + + it(`handles all suspended`, () => { + const chunks = chunkedRenderTasks( + [ + keystroke({ duringSuspension: true }), + screenshot({ duringSuspension: true }), + screenshot({ duringSuspension: true }), + screenshot({ duringSuspension: true }), + ], + 2, + ); + expect(simplify(chunks)).toMatchInlineSnapshot(` + [ + "suspension_group(KeystrokeLine, Screenshot)", + "delete_btn", + "suspension_group(Screenshot, Screenshot)", + ] + `); + }); + + it(`merges stray keystroke lines into suspension group`, () => { + const chunks = chunkedRenderTasks( + [ + screenshot({ + duringSuspension: true, + date: time.subtracting({ minutes: 1 }), + }), + keystroke({ + duringSuspension: false, // <-- stray keystroke line + date: time.subtracting({ minutes: 2 }), + }), + screenshot({ + duringSuspension: true, + date: time.subtracting({ minutes: 3 }), + }), + screenshot(), + ], + 100, + ); + expect(simplify(chunks)).toMatchInlineSnapshot(` + [ + "suspension_group(Screenshot, KeystrokeLine, Screenshot)", + "item(Screenshot)", + ] + `); + }); + + it(`does not merge keystroke if too far`, () => { + const chunks = chunkedRenderTasks( + [ + screenshot({ + duringSuspension: true, + date: time.subtracting({ minutes: 1 }), + }), + keystroke({ + duringSuspension: false, + date: time.subtracting({ minutes: 5.1 }), + }), + screenshot({ + duringSuspension: true, + date: time.subtracting({ minutes: 5.2 }), // too far from prev + }), + screenshot(), + ], + 100, + ); + expect(simplify(chunks)).toMatchInlineSnapshot(` + [ + "suspension_group(Screenshot)", + "item(KeystrokeLine)", + "suspension_group(Screenshot)", + "item(Screenshot)", + ] + `); + }); +}); + +// helpers + +function simplify(chunks: ReturnType): string[] { + return chunks + .map((chunk) => + chunk.map((i) => { + switch (i.type) { + case `item`: + return `item(${i.item.type})`; + case `delete_btn`: + return `delete_btn`; + default: + return `suspension_group(${i.items.map((i) => i.type).join(`, `)})`; + } + }), + ) + .flat(1); +} + +function screenshot(config: Partial> = {}): Chunkable { + return { + type: `Screenshot`, + id: nextId(), + duringSuspension: false, + date: time.stable(), + ...config, + }; +} + +function keystroke(config: Partial> = {}): Chunkable { + return { + type: `KeystrokeLine`, + id: nextId(), + duringSuspension: false, + date: time.stable(), + ...config, + }; +} + +let id = 0; +function nextId(): string { + return String(++id); +} +function resetId(): void { + id = 0; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f8f47ee..2e03cd52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,6 +283,9 @@ importers: specifier: 9.0.0 version: 9.0.0 devDependencies: + '@shared/datetime': + specifier: workspace:* + version: link:../../shared/datetime '@types/uuid': specifier: 8.3.4 version: 8.3.4