import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { Loader2, Eye, PencilLine, LogIn, LogOut, Stethoscope, ChevronRight, Bell, VideoOff, Users } from 'lucide-react'; import { useMyHistoryService, type MyHistoryResponse } from '../../../services/MyHistoryService.tsx'; import './MyHistoryPage.css'; import { useActAs } from '../../../hooks/useActAs'; import { forThisViewer, ownedRead, type ReadRequest } from '../../../utils/ownedRead'; import { onViewerChange, viewerEpoch } from '../../../utils/writeThroughHold'; import { HttpError } from '../../../services/HttpService.tsx'; import { collapseHistory, didNotHappen } from '../../../utils/history/collapseHistory'; import { describeEarlierDoor, describeEmptyWindow, describeHistoryWindow, SEARCH_EARLIER_LABEL, STALLED_HISTORY_LINE, } from '../../../utils/history/describeHistoryWindow'; import type { HistoryRow, MyHistoryEvent } from '../../../utils/history/collapseHistory'; import { formatSessionDuration } from '../../../utils/history/formatSessionDuration'; import { describeChange, DID_NOT_GO_THROUGH } from '../../../utils/history/describeChange'; import { HISTORY_TABS, filterRowsForTab } from '../../../utils/history/historyFilters'; import type { HistoryTabId } from '../../../utils/history/historyFilters'; import { diffResourceVersions } from '../../../utils/history/diffResourceVersions'; import { describePatientChange } from '../../../utils/history/describePatientChange'; import { describeCareChange } from '../../../utils/history/describeCareChange'; import { isHistoryComplete, selectVersionPair } from '../../../utils/history/selectVersionPair'; import type { VersionedResource } from '../../../utils/history/selectVersionPair'; import { Tabs, TabsList, TabsTrigger } from '../../../components/ui/tabs'; import { EmptyState } from '../../../components/ui/empty-state'; import { accessThingsLabel, accessTimesLabel, groupAccessDetail, } from '../../../utils/history/groupAccessDetail'; import { formatHistoryClock, formatHistoryClockRange, formatHistoryMoment, } from '../../../utils/history/formatHistoryTime'; // / My History -- the patient's own record of who touched their data. // // An AWARENESS tool, not the organization's compliance artifact: court-grade reconstruction stays with // the care-view anchor program. See akashic-docs/audit-and-compliance/patient-my-history-design.md. // Minute-level and month-named. `toLocaleString()` printed "9/4/2026, 8:54:12 PM" here - see // formatHistoryMoment for why both halves of that were wrong on this screen in particular. const formatTime = (iso: string | undefined): string => formatHistoryMoment(iso); // A session row's label is a STATUS ("Session ended"), the others are verb phrases ("accessed your // record"). So a session reads "You - Session ended" and everything else reads "Emily Larson accessed // your record" - actor inline either way, one line per row (Spencer, 2026-07-30). // Session and visit labels are STATUSES ("Session ended", "Visit started"); access and change are verb // phrases ("accessed your record"). So the separator differs by kind - actor inline either way. // A PAIRED session spans two instants, so it reads as a range: "9:04 AM - 9:26 AM". Everything else is // a moment. An unpaired session (no session id, or no matching end yet) falls back to the single time // rather than rendering an empty or duplicated range. const formatTimeRange = (row: HistoryRow): string => { if (row.kind !== 'session' || !row.to || row.to === row.from) return formatTime(row.from); const sameDay = row.from && new Date(row.from).toDateString() === new Date(row.to).toDateString(); return `${formatTime(row.from)} – ${sameDay ? formatHistoryClock(row.to) : formatTime(row.to)}`; }; const conferenceRefused = (row: HistoryRow): boolean => didNotHappen(row.detail[0]?.outcome); const actorSeparator = (row: HistoryRow): string => (row.kind === 'session' || (row.kind === 'conference' && !conferenceRefused(row)) || row.kind === 'visit' || row.kind === 'reminder' || row.kind === 'proxySession' ? ' · ' : ' '); // A helper's act-as session, as the server classified each end. A timeout is said only where the server recorded one. const HELPER_SESSION_WORDS = { start: 'Started acting for you', exit: 'Stopped acting for you', timeout: 'Their session acting for you timed out', } as const; const describeRow = (row: HistoryRow): string => { // S2 - RENDER THE SERVER'S WORDING VERBATIM. "Signed out" (the user explicitly ended the // session) and "Session ended" (a TTL expired; nobody ever saw them leave) are different claims, // and only the server knows which one is true. Composing our own sentence here would either // collapse the distinction or overstate it. The fallback deliberately says nothing specific // rather than guessing at one of the two. // Same rule for visits: "Visit started"/"Visit ended" are chosen server-side at the only point that // knows the encounter's status transition, and rendered verbatim. if (row.kind === 'session' || row.kind === 'visit') { return row.entityName ?? (row.kind === 'visit' ? 'visit activity' : 'session activity'); } // Same rule for reminders: "sent", "not sent" and "failed" are the sweep's words, not an edit. if (row.kind === 'reminder') return row.entityName ?? 'appointment reminder'; if (row.kind === 'proxySession') return row.accessSession ? HELPER_SESSION_WORDS[row.accessSession] : 'session activity'; // Only that it took place, or that a start was refused; never who else was in it or what was said. if (row.kind === 'conference') { return conferenceRefused(row) ? `tried to start a case conference about your care — ${DID_NOT_GO_THROUGH}` : 'Case conference about your care'; } // A refused join says only that it was tried; never the reason, the visit or a practice. if (row.kind === 'joinAttempt') return `tried to join a video visit — ${DID_NOT_GO_THROUGH}`; // "changed your Communication" / "changed your DirectThread" leaked raw type names at a patient. // describeChange says what the thing IS in their words, and falls back to plain language rather // than the type name for anything unmapped. // // IT ALSO SAYS WHICH VERB, which this line used to decide for itself. Every change row read // "updated", so a creation - an order placed, a note written - told the patient something about // them had been altered. The verb belongs to the audit action the server sends, and it is passed // through rather than inferred. // A collapsed row can mix ordinary reads with assistant disclosures; it names only what happened. const disclosures = row.kind === 'access' ? row.detail.filter((each) => each.disclosure === 'assistant').length : 0; if (disclosures > 0 && disclosures === row.detail.length) return 'asked the assistant, which showed part of your record'; if (disclosures > 0) return 'accessed your record, including asking the assistant'; return row.kind === 'access' ? 'accessed your record' : describeChange(row.detail[0]?.action, row.detail[0]?.resourceType, row.detail[0]?.outcome); }; // What a change row's expansion is showing. `unavailable` is a FIRST-CLASS state, not an error path: // the version pair may be undeterminable, the row may be a creation, or the type may not be described // yet. Saying so plainly beats inventing a change that did not happen - this feed's credibility is the // product. type ChangeDetail = | { state: 'loading' } | { state: 'ready'; lines: string[] } | { state: 'unavailable'; message: string }; // Option A (Spencer, 2026-07-30): describe Patient deeply first - the resource a patient edits most // about themselves, and the case that prompted this. Other types get an honest "not yet" rather than a // generic diff, because the labelling is what makes the expansion worth opening, and a half-labelled // row would reintroduce the raw-path problem this feature just removed. // Which types this client can DESCRIBE in the patient's words. Purely a // presentation concern. // // NOT a permission list. Whether a change may be opened AT ALL is the server's `expandable` flag - // expanding fetches the resource's _history, a second read path the feed's redaction filter does not // cover, so it is a confidentiality decision and belongs at the boundary. This set only decides whether // we can phrase the result nicely once the server has allowed it. Widening it grants nothing. const DESCRIBED_TYPES = new Set([ 'Patient', 'Appointment', 'Task', 'Consent', 'CarePlan', 'Observation', 'AllergyIntolerance', 'Condition', 'MedicationStatement', 'Immunization', 'Procedure', 'FamilyMemberHistory', 'DocumentReference', ]); // Mirrors the server's DEFAULT_COUNT. Used only to infer "a full page came back, so there may be more" // - the server is still the authority on how much it returns. const PAGE_SIZE = 100; // How long a history read may take before the screen says it could not be loaded. const HISTORY_READ_DEADLINE_MS = 20_000; // Where the next page starts, from one answer to a read sent with `before`. A remainder the server cannot point // past (no cursor, or the one just asked with) is stalled: never offered as more, never read as the end. // `inferred` stands in only for a server that sends neither flag nor cursor. const continuationOf = (response: MyHistoryResponse, before: string | undefined, inferred: boolean) => { const more = response.mayHaveOlder ?? inferred; const moved = response.olderThan !== undefined && response.olderThan !== before; const stalled = more && !moved && (response.mayHaveOlder !== undefined || response.olderThan !== undefined); return { mayHaveOlder: more && !stalled, stalled, olderThan: stalled ? before : response.olderThan }; }; // An event belongs to exactly one row, so its id names that row across tabs and pages. const historyRowKey = (row: HistoryRow): string => `${row.kind}:${row.detail[0]?.id ?? row.sessionId ?? `${row.actorReference ?? 'unknown'}-${row.from}`}`; const MyHistoryPage = () => { const { fetchMyHistory, fetchResourceHistory } = useMyHistoryService(); // WHOSE record this feed is. `null` when the viewer is looking at their own, and the subject's // patient id while a guardian has switched into a dependent. Normalised to null rather than left // as `undefined` so the two "my own record" states cannot read as two different subjects and // reload the feed for no reason. const { actingAs } = useActAs(); const subjectPatientId = actingAs?.subjectPatientId ?? null; // One opening per viewer, practice and acted-as subject; going from A to B and back to A is a new opening. const viewer = useSyncExternalStore(onViewerChange, viewerEpoch); const scope = viewer + '|' + (subjectPatientId ?? ''); const opening = useRef({ scope, n: 0 }); if (opening.current.scope !== scope) opening.current = { scope, n: opening.current.n + 1 }; // Bumped by every first page, so an older page or detail asked for under a replaced page is dropped. const feed = useRef(0); const reads = useRef(new Set()); // The opening the state below was set for; until the new opening has reset it, nothing of the last is drawn. const [shownFor, setShownFor] = useState(opening.current.n); const retireReads = () => { reads.current.forEach((read) => read.abort()); reads.current.clear(); }; // Which first page the rows on screen came from, and the feed whose first page is still pending. A paging // click drawn over another first page, or made while one is pending, would page a replaced snapshot. const firstPage = useRef<{ shown: number; pending?: number }>({ shown: 0 }); const drawnFrom = firstPage.current.shown; const ownsNow = () => { const n = opening.current.n; const page = feed.current; return forThisViewer(() => opening.current.n === n && feed.current === page); }; const owned = (work: (request: ReadRequest) => Promise, owns: () => boolean) => ownedRead(work, { deadlineMs: HISTORY_READ_DEADLINE_MS, owns, track: reads.current }); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(false); // A failed Refresh keeps the rows already shown and says they may be out of date. const [refreshError, setRefreshError] = useState(false); // A failed "Show older" keeps the rows already on screen and says so beside the control. const [olderError, setOlderError] = useState(false); // Keyed by the row's own event, never its position: positions shift between tabs and pages. const [expanded, setExpanded] = useState>({}); const [tab, setTab] = useState('all'); // - what actually changed, fetched lazily per row. Keyed by the audit event id rather than // the row index, because switching tabs reorders indices and would otherwise show one row's detail // under another. const [changeDetail, setChangeDetail] = useState>({}); // - Spencer, 2026-07-30: "I had to refresh the page for it to appear." Reloading the whole // portal to check whether an edit was recorded is not a reasonable ask, so the feed refreshes itself. const [refreshing, setRefreshing] = useState(false); // Raw events are kept alongside the collapsed rows because paging older APPENDS: collapsing must be // re-run over the whole set, or a session whose two ends straddle a page boundary would never pair, // and reads either side of the boundary would form two access rows instead of one. const [events, setEvents] = useState([]); const [loadingOlder, setLoadingOlder] = useState(false); // Where the next page starts, as the server last reported it. Held rather than derived: the rows // on screen are the survivors of the server's filter, so the oldest of them is not where the // server stopped reading, and paging from it re-asks for a window we already have. const [olderThan, setOlderThan] = useState(undefined); // Undefined until we know: only a FULL page implies there may be more. A short page means we reached // the end, and offering "show older" there would promise something that does not exist. const [mayHaveOlder, setMayHaveOlder] = useState(false); // The server said there is more but gave no cursor past it; shown as stalled, with a retry. const [stalled, setStalled] = useState(false); // The default window's floor, from the first page only: the door to activity before it. const [earlierThan, setEarlierThan] = useState(undefined); // The first page, for a new opening or its retry (`initial`) or for Refresh over the rows already shown. const readFirst = async (initial: boolean) => { retireReads(); feed.current += 1; const owns = ownsNow(); firstPage.current.pending = feed.current; if (initial) { setLoading(true); setLoadError(false); } else { setRefreshing(true); } setLoadingOlder(false); setOlderError(false); try { const response = await owned((request) => fetchMyHistory(undefined, {}, request), owns); if (!owns()) return; const first = response.events; firstPage.current.shown += 1; setEvents(first); setRows(collapseHistory(first, { joinAttempts: true })); // The server says whether it has more; a short first page is not an empty record. An // absent flag falls back to the old length test rather than promising a page that may // not exist. const next = continuationOf(response, undefined, first.length >= PAGE_SIZE); setMayHaveOlder(next.mayHaveOlder); setStalled(next.stalled); setOlderThan(next.olderThan); setEarlierThan(response.earlierThan); setRefreshError(false); // Drop cached expansions: a refreshed feed may reorder or replace rows, and detail keyed to // a stale event id would be shown against the wrong one. Close the rows with it, or an open // change row would wait on detail nobody is fetching. setChangeDetail({}); setExpanded({}); } catch (error) { if (!owns()) return; console.error(initial ? 'Failed to load history:' : 'Failed to refresh history:', error); // A failure must NEVER render as an empty feed. "No activity on your record" is the // one lie this feature cannot tell -- it would read as "nobody accessed your data". if (initial) setLoadError(true); else setRefreshError(true); } finally { if (owns()) { firstPage.current.pending = undefined; if (initial) setLoading(false); else setRefreshing(false); } } }; useEffect(() => { setShownFor(opening.current.n); setRefreshing(false); setRefreshError(false); // DROP THE PREVIOUS SUBJECT'S FEED BEFORE FETCHING, not after it returns. Between the // switch and the response the page would otherwise still be showing - and still able to // expand - rows belonging to whoever was being viewed a moment ago. setRows([]); setEvents([]); setChangeDetail({}); setExpanded({}); setMayHaveOlder(false); setStalled(false); setOlderThan(undefined); setEarlierThan(undefined); void readFirst(true); return () => { feed.current += 1; retireReads(); }; // RELOAD WHEN THE SUBJECT CHANGES, AND THIS DEPENDENCY IS THE WHOLE FIX. // // This effect used to run once on mount. The feed is WHOSE RECORD IS BEING VIEWED, so a // guardian who switched into a child, opened this page, then switched back out was left // holding the CHILD's rows under their own identity. Expanding one then asked for // `Patient//_history` as the guardian, and the server refused it - correctly. // The request should never have been made. // // Keying on the subject id rather than on a boolean "am I acting as someone": switching // directly from one dependent to another changes neither the mount nor the boolean, and that // is the case that would still have shown one child's history under another child's name. // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope]); if (loading || shownFor !== opening.current.n) { return (

); } if (loadError) { return (

Your history could not be loaded. Please try again.

); } // The empty feed deliberately does NOT return early any more. It used to, which meant the tabs and // the Refresh control disappeared in exactly the situation where refreshing is most wanted: you just // made a change, the async audit chain has not caught up, and the screen says nothing happened. The // way out must not be to reload the whole portal. // Design §5: 90 days is a DEFAULT, not a cap. Without this the older record was simply // unreachable - the server accepted a cursor and the client never sent one, so the promise was kept // on one side of the wire and broken on the other. // The server's own cursor first. Falling back to the oldest row on screen only covers a server // that does not send one; it cannot be the primary, because a page whose rows were all filtered // away leaves that value unmoved and the next request repeats the last one exactly. const olderCursor = (): string | undefined => olderThan ?? events.reduce( (earliest, item) => (item.recorded && (!earliest || item.recorded < earliest) ? item.recorded : earliest), undefined, ); // One older read from `cursor`: the next page, a stalled retry, or the earlier door's `before=earlierThan`. const loadOlder = async (cursor: string | undefined) => { if (!cursor || firstPage.current.pending === feed.current || drawnFrom !== firstPage.current.shown) return; const owns = ownsNow(); setLoadingOlder(true); setOlderError(false); try { const response = await owned((request) => fetchMyHistory(cursor, {}, request), owns); if (!owns()) return; const older = response.events; // De-duplicate by id. The server bounds with `le` rather than `lt` deliberately - several // rows can share a timestamp and `lt` would skip one, dropping an access from the record - // so the boundary row comes back again by design and is dropped here instead. const seen = new Set(events.map((item) => item.id)); const added = older.filter((item) => item.id && !seen.has(item.id)); const merged = [...events, ...added]; setEvents(merged); // Re-collapse over the WHOLE set, never just the appended page: a session whose two ends // straddle a page boundary would otherwise never pair, and reads either side would form two // access rows instead of one. setRows(collapseHistory(merged, { joinAttempts: true })); // A short page is not the end of the record: take what the server says, infer only from a // server that says nothing, and call a cursor that did not move stalled rather than the end. const next = continuationOf(response, cursor, added.length > 0); setMayHaveOlder(next.mayHaveOlder); setStalled(next.stalled); setOlderThan(next.olderThan); // Past the default window now, so its door is spent. setEarlierThan(undefined); } catch (error) { if (!owns()) return; console.error('Failed to load older history:', error); setOlderError(true); } finally { if (owns()) setLoadingOlder(false); } }; // A stalled first page has no cursor to retry from, so it is read again; a stalled older page re-asks its own. const retryStalled = () => void (olderThan ? loadOlder(olderThan) : readFirst(false)); // Offered only once the default window has nothing further and the server named its floor. const earlierDoor = !mayHaveOlder && !stalled ? earlierThan : undefined; // Read at render from the rows themselves rather than held in state: the far edge changes // whenever a page lands, and a second copy of it is a second thing to keep in step. const oldestRowDate = rows.reduce( (earliest, row) => (row.from && (!earliest || row.from < earliest) ? row.from : earliest), undefined, ); const visibleRows = filterRowsForTab(rows, tab); // The window is part of the claim: always a date the server's search can be traced to. A stall // still has more behind it, so it names the oldest loaded row rather than a floor. const searched = { mayHaveOlder: mayHaveOlder || stalled, olderThan, earlierThan, oldest: oldestRowDate }; const windowLine = (

{describeHistoryWindow(searched)}

); // Offered only when a FULL page came back: at the true end it would promise history that // does not exist. const olderControl = ( <> {mayHaveOlder && ( )} {stalled && (

{STALLED_HISTORY_LINE}

)} {earlierDoor && (

{describeEarlierDoor(searched)}

)} {olderError && (

Older activity could not be loaded. Your history above is unchanged; try again.

)} ); // Fetches the resource's version history, pairs it with this audit row, and turns the difference // into sentences. Everything here fails toward "we cannot say" rather than toward a guess. const loadChangeDetail = async (row: HistoryRow) => { const event = row.detail[0]; const key = event?.id; if (!key || changeDetail[key]) return; const { resourceType, resourceId, recorded, versionId } = event; const owns = ownsNow(); // The server's flag is the gate; DESCRIBED_TYPES only decides whether we can phrase the result. // Read off the EVENT rather than the row: the event is what the server sent, and copying the // flag onto the collapsed row would create a second place for it to be wrong. // // Absent means NOT expandable - an older server that predates the flag must never have the // expansion assumed open. // // - TWO REASONS A ROW WILL NOT OPEN, AND THEY ARE NOT THE SAME SENTENCE. // // Both used to read "A detailed view of this change is not available yet", and for one of them // "yet" is a promise nothing intends to keep. The server still refuses Invoice, Coverage, // Communication and the rest of the org-internal middle on a confidentiality ground: expanding // fetches the resource's `_history` directly, and for a type whose handler does not redact, that // is a second read path the feed withheld from. Telling the patient to wait for that is telling // them to wait for nothing. // // The other reason IS temporary: the server allows the read and this client cannot yet put the // difference into the patient's words. Patient is described deeply; the rest are waiting their // turn, which is what "not yet" honestly means. // // Neither sentence names a resource type. "Appointment" is a FHIR word, and the whole point of // this panel is that a patient never meets one. if (!resourceType || !resourceId) { setChangeDetail((current) => ({ ...current, [key]: { state: 'unavailable', message: 'We could not match this entry to a specific change.' }, })); return; } if (!event.expandable) { setChangeDetail((current) => ({ ...current, [key]: { state: 'unavailable', message: 'This kind of change is not opened here. You can see that it happened, ' + 'who made it and when, but not what was written.', }, })); return; } if (!DESCRIBED_TYPES.has(resourceType)) { setChangeDetail((current) => ({ ...current, [key]: { state: 'unavailable', message: 'The details of this kind of change are not described yet.' }, })); return; } setChangeDetail((current) => ({ ...current, [key]: { state: 'loading' } })); try { const history = await owned((request) => fetchResourceHistory(resourceType, resourceId, request), owns); if (!owns()) return; const versions = (history?.entry ?? []) .map((entry) => entry?.resource) .filter(Boolean) as VersionedResource[]; const pair = selectVersionPair(versions, recorded, versionId, isHistoryComplete(history)); let previous: VersionedResource | undefined; let currentVersion: VersionedResource | undefined; if (pair.ok) { previous = pair.previous; currentVersion = pair.current; } else if (pair.reason === 'created') { currentVersion = versions .filter((version) => version.meta?.lastUpdated) .sort((a, b) => String(a.meta?.lastUpdated).localeCompare(String(b.meta?.lastUpdated)))[0]; if (currentVersion) { previous = { resourceType: currentVersion.resourceType }; } } if (!previous || !currentVersion) { setChangeDetail((current) => ({ ...current, [key]: { state: 'unavailable', message: 'We could not match this entry to a specific change.', }, })); return; } // ONE CHANGE IS ONE SENTENCE, however many leaves moved underneath it. // // `diffResourceVersions` walks to LEAVES, and `describePatientChange` names the field from // the path ROOT. A coded field is three leaves - `maritalStatus.coding[0].code`, `.system` // and `.display` - so editing marital status once produced "Changed your marital status" // three times on the patient's own screen. Every coded field behaves this way; it was not // about marital status. Two staff rows that each changed one simple field are why an // earlier pass over this panel called the machinery correct and did not see it. // // De-duplicating the SENTENCES rather than the paths is deliberate. The patient is being // told what changed about them, and "your marital status changed" is one fact whether the // system moved one subfield or three. Two genuinely different changes still read as two // lines, because they describe differently - a contact's sentence carries that contact's // name, so two contacts editing the same field do not collapse into one. const lines = [...new Set( diffResourceVersions(previous, currentVersion) .map((changed) => (resourceType === 'Patient' ? describePatientChange(changed, currentVersion) : describeCareChange(resourceType, changed))) .filter((line): line is string => Boolean(line)) )]; setChangeDetail((current) => ({ ...current, [key]: lines.length ? { state: 'ready', lines } // A pair with no visible difference is a real outcome: the write touched only fields // the differ ignores as plumbing. Say that rather than showing an empty panel. : { state: 'unavailable', message: 'No visible details changed in this update.' }, })); } catch (error) { if (!owns()) return; // A REFUSAL IS NOT A FAILURE, AND SAYING SO COST A DAY OF SOMEBODY ELSE'S TIME. // // Every error used to land on "The details of this change could not be loaded", which reads // as a network problem. When the server correctly refused a record the caller was not // acting for, that sentence sent two sessions and the captain into IAM looking for a policy // hole that was never there - the authorizer was working. A denial that misreports its own // cause does not merely confuse the reader; it misdirects whoever investigates it. // // Spencer, 2026-09-06: a refusal names what is denied and never reads as a failure. const refused = error instanceof HttpError && (error.status === 403 || error.status === 401); // Refusals log at debug, not error: a correct denial is not a fault of this client, and // logging it as one is the same misreport in the console that the message was on screen. if (refused) console.debug('my-history: refused the change detail', error); else console.error('my-history: could not load the change detail', error); setChangeDetail((current) => ({ ...current, [key]: { state: 'unavailable', message: refused ? 'You do not have permission to see the details of this change.' : 'The details of this change could not be loaded.', }, })); } }; const rowIcon = (row: HistoryRow) => { // Direction matters on session rows, keyed off the SERVER's label: the client must not decide // whether a session ended deliberately. if (row.kind === 'visit') return