Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions docs/dashboard-activity-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@ centralized in `EVENT_SUBTYPE`, so renaming later is a one‑file change.
## Fire‑timing policy

- **Navigation/intent buttons** (no meaningful success state) → fire on click.
- **Mutation buttons** (send/approve/reject/resolve/start) → fire on **success**
only, so failed/cancelled attempts don't pollute activation metrics.
- **Mutation buttons** (send/approve/reject/start) → fire on **success** only, so
failed/cancelled attempts don't pollute activation metrics.
- **Resolve ticket** → fire **optimistically on click** (when a RESOLVED-kind
status is chosen), *not* on success. A resolve can happen via two mutations
(the status changer and the Edit form), and success callbacks proved
unreliable to observe; losing one event on a rare failed transition is
acceptable, missing real resolves is not.
- **Skip onboarding** → fire when the walkthrough is dismissed.

## Tracked events
Expand All @@ -67,9 +72,9 @@ centralized in `EVENT_SUBTYPE`, so renaming later is a one‑file change.
| 4 | Send Mingo message | `SEND_MINGO_MESSAGE` | `send_mingo_message_mingo` | mingo | `mingo/page.tsx` → `handleSendMessage` | send **success** (`success === true`, both draft & existing branches) |
| 5 | Approve Mingo command | `APPROVE_MINGO_COMMAND` | `approve_mingo_command_mingo` | mingo | `mingo/hooks/use-mingo-dialog-selection.ts` → `handleApprove` | approve mutation **success** |
| 6 | Reject Mingo command *(added per request)* | `REJECT_MINGO_COMMAND` | `reject_mingo_command_mingo` | mingo | `mingo/hooks/use-mingo-dialog-selection.ts` → `handleReject` | reject mutation **success** |
| 7 | Open Remote Shell | `OPEN_REMOTE_SHELL` | `open_remote_shell_ticket_detail` | ticket detail | `tickets/components/ticket-details-view.tsx` → `menuActions` memo wraps the `remoteShell` item (and its Windows submenu leaves) with an `onClick` | menu item click |
| 8 | Open Remote Control | `OPEN_REMOTE_CONTROL` | `open_remote_control_ticket_detail` | ticket detail | `tickets/components/ticket-details-view.tsx` → `menuActions` memo wraps the `remoteControl` item with an `onClick` | menu item click |
| 9 | Resolve ticket | `RESOLVE_TICKET` | `resolve_ticket_ticket_detail` | ticket detail | `tickets/components/ticket-details-view.tsx` → `handleResolve` | resolve **success** (`nextStatus` truthy) |
| 7 | Open Remote Shell | `OPEN_REMOTE_SHELL` | `open_remote_shell_ticket_detail` | ticket detail | `tickets/components/ticket-details-view.tsx` → `menuActions` memo wraps the `remoteShell` item (and its Windows submenu leaves) via `withActivityTracking` | menu item click |
| 8 | Open Remote Control | `OPEN_REMOTE_CONTROL` | `open_remote_control_ticket_detail` | ticket detail | `tickets/components/ticket-details-view.tsx` → `menuActions` memo wraps the `remoteControl` item via `withActivityTracking` | menu item click |
| 9 | Resolve ticket | `RESOLVE_TICKET` | `resolve_ticket_ticket_detail` | ticket detail | `handleTransition` (detail-view status changer) **and** `useCreateTicketForm.handleSave` (Edit Ticket form), both via `isResolvedStatusId` | click, when target status kind is RESOLVED (optimistic, before the mutation) |
| 10 | Start Direct Chat | `START_DIRECT_CHAT` | `start_direct_chat_ticket_detail` | ticket detail | `tickets/hooks/use-direct-chat.ts` → `createDialogMutation.onSuccess` | dialog create **success** |

> The Approve/Reject buttons themselves and the onboarding buttons render inside
Expand All @@ -81,13 +86,18 @@ centralized in `EVENT_SUBTYPE`, so renaming later is a one‑file change.
### Remote Shell / Remote Control nuance

`buildDeviceMenuItems` (`devices/utils/device-menu-items.tsx`) returns
`ActionsMenuItem`s that navigate via `href`. `ActionsMenuItem` supports `href`
**and** `onClick` together, so in the `menuActions` memo we shallow‑wrap the
built `remoteShell` / `remoteControl` items adding an `onClick` that calls
`trackDashboardActivity(...)` while leaving `href` untouched. On Windows,
`remoteShell` is a `type: 'submenu'` whose leaf children carry the `href`; the
wrapper maps those leaves too so the event fires on the actual shell choice.
`iconAction` (open‑in‑new‑tab secondary button) is left as‑is.
`ActionsMenuItem`s that navigate via `href`, which the core `ActionsMenu`
renders as a `<Link>`. Whether a link row also invokes the item's `onClick` is a
core‑lib implementation detail we must **not** let the analytics silently depend
on — it regressed once and dropped these two events entirely. So
`withActivityTracking` (in `ticket-details-view.tsx`) **drops `href`** and drives
navigation itself: the wrapped `onClick` calls `trackDashboardActivity(...)` then
`router.push(href)`. That forces the reliable button‑row path whose `onClick`
always fires. On Windows, `remoteShell` is a `type: 'submenu'` whose leaf
children carry the `href`; the wrapper recurses into those leaves so the event
fires on the actual shell choice. Trade‑off: the row is no longer a native anchor,
so cmd/middle‑click "open in new tab" on the row is gone (the ticket menu has no
`iconAction` new‑tab button anyway).

## Non‑functional requirements

Expand Down
47 changes: 37 additions & 10 deletions src/app/(app)/tickets/components/ticket-details-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,10 @@ import { useAddTicketNote, useDeleteTicketNote, useUpdateTicketNote } from '../h
import { useAssigneeOptions } from '../hooks/use-ticket-options';
import { useTicketStatus } from '../hooks/use-ticket-status';
import { useTransitionTicket } from '../hooks/use-transition-ticket';
import { useTicketStatusesQuery } from '../statuses/hooks/use-ticket-statuses-query';
import { useTicketDetailsStore } from '../stores/ticket-details-store';
import type { ClientDialogOwner, Dialog, DialogOwner } from '../types/dialog.types';
import { isResolvedStatusId } from '../utils/is-resolved-status';
import { ticketsQueryKeys } from '../utils/query-keys';
import { TICKET_STATUS_KIND } from '../utils/ticket-statistics';
import { TicketAttachmentsSection } from './ticket-attachments-section';
Expand All @@ -103,20 +105,32 @@ interface TicketDetailsViewProps {

/**
* Wrap a device-menu item so opening it also fires a dashboard-activity event.
* `href` navigation is preserved. For a submenu parent the click only expands
* the submenu, so tracking is attached to the leaf items that actually
* navigate, not the parent.
*
* The built device items navigate via `href`, which the core `ActionsMenu`
* renders as a `<Link>`. Whether a link row also invokes the item's `onClick`
* is a core-lib implementation detail we must not let the analytics silently
* depend on (it regressed once, dropping open_remote_shell/open_remote_control
* events entirely). So instead of attaching an `onClick` alongside `href`, we
* drop `href` and drive navigation ourselves inside `onClick` (track +
* `navigate`). That forces the reliable button-row path whose `onClick` always
* fires. For a submenu parent the click only expands the submenu, so we recurse
* into the leaf items that actually navigate, not the parent.
*/
function withActivityTracking(item: ActionsMenuItem, subtype: EventSubtype): ActionsMenuItem {
function withActivityTracking(
item: ActionsMenuItem,
subtype: EventSubtype,
navigate: (href: string) => void,
): ActionsMenuItem {
if (item.submenu && item.submenu.length > 0) {
return { ...item, submenu: item.submenu.map(child => withActivityTracking(child, subtype)) };
return { ...item, submenu: item.submenu.map(child => withActivityTracking(child, subtype, navigate)) };
}
const originalOnClick = item.onClick;
const { href, onClick: originalOnClick, ...rest } = item;
return {
...item,
...rest,
onClick: () => {
trackDashboardActivity(subtype);
originalOnClick?.();
if (href) navigate(href);
},
};
}
Expand Down Expand Up @@ -252,6 +266,9 @@ export function TicketDetailsView({ ticketId }: TicketDetailsViewProps) {

const { activate, archive, isUpdating } = useTicketStatus();
const transitionTicket = useTransitionTicket();
// Target-status kinds, to recognize a resolve at click time (availableTransitions
// carries no `kind`). Cached/shared with the board & table, so no extra fetch.
const { data: statusesData } = useTicketStatusesQuery();
const { handleApproveRequest, handleRejectRequest } = useApprovalRequests();

// Time tracker lives in a global host provider (mounted when the feature flag
Expand Down Expand Up @@ -407,9 +424,17 @@ export function TicketDetailsView({ ticketId }: TicketDetailsViewProps) {
const handleTransition = useCallback(
(toStatusId: string) => {
if (!dialog || transitionTicket.isPending) return;
// Resolve is the inline status changer moving the ticket into a
// RESOLVED-kind status — there is no dedicated "resolve" button. Track
// optimistically on click (like the other activity events): losing one
// event on a failed transition is fine; missing real resolves because a
// success callback didn't land is not.
if (isResolvedStatusId(toStatusId, statusesData?.snapshot)) {
trackDashboardActivity(EVENT_SUBTYPE.RESOLVE_TICKET);
}
transitionTicket.mutate({ ticketId, toStatusId });
},
[dialog, ticketId, transitionTicket],
[dialog, ticketId, transitionTicket, statusesData],
);

const handleApprovalAction = useCallback(
Expand Down Expand Up @@ -542,8 +567,10 @@ export function TicketDetailsView({ ticketId }: TicketDetailsViewProps) {
if (deviceDetails || isDeviceLoading) {
infoItems.push(deviceMenuItems.deviceDetails, deviceMenuItems.deviceLogs);
remoteItems.push(
withActivityTracking(deviceMenuItems.remoteShell, EVENT_SUBTYPE.OPEN_REMOTE_SHELL),
withActivityTracking(deviceMenuItems.remoteControl, EVENT_SUBTYPE.OPEN_REMOTE_CONTROL),
withActivityTracking(deviceMenuItems.remoteShell, EVENT_SUBTYPE.OPEN_REMOTE_SHELL, href => router.push(href)),
withActivityTracking(deviceMenuItems.remoteControl, EVENT_SUBTYPE.OPEN_REMOTE_CONTROL, href =>
router.push(href),
),
deviceMenuItems.manageFiles,
deviceMenuItems.runScript,
);
Expand Down
8 changes: 8 additions & 0 deletions src/app/(app)/tickets/hooks/use-create-ticket-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useApplyAssignmentsDiff, useAssignedItems } from '@/components/assignments';
import { EVENT_SUBTYPE, trackDashboardActivity } from '@/lib/analytics';
import { apiClient } from '@/lib/api-client';
import { API_ENDPOINTS, CREATION_SOURCE } from '../constants';
import { GET_TICKET_QUERY } from '../queries/ticket-queries';
Expand All @@ -13,6 +14,7 @@ import { type CreateTicketFormData, createTicketSchema } from '../types/create-t
import type { Ticket } from '../types/ticket.types';
import type { GraphQlResponse } from '../utils/graphql';
import { extractGraphQlData } from '../utils/graphql';
import { isResolvedStatusId } from '../utils/is-resolved-status';
import { ticketsQueryKeys } from '../utils/query-keys';
import { resolveCurrentStatus } from '../utils/resolve-current-status';
import { useCreateTicket } from './use-create-ticket';
Expand Down Expand Up @@ -119,6 +121,12 @@ export function useCreateTicketForm({ ticketId }: UseCreateTicketFormOptions = {
// Transition first: updateTicket's onSuccess navigates away, so a failed
// transition afterwards would strand the user on the next page mid-error.
if (data.statusId && data.statusId !== currentStatus?.id) {
// Editing a ticket into a RESOLVED-kind status is also a "resolve".
// Track optimistically before the mutation, same as the detail-view
// status changer (see isResolvedStatusId).
if (isResolvedStatusId(data.statusId, statusesQuery.data?.snapshot)) {
trackDashboardActivity(EVENT_SUBTYPE.RESOLVE_TICKET);
}
await transitionTicketMutation.mutateAsync({ ticketId, toStatusId: data.statusId });
}

Expand Down
20 changes: 20 additions & 0 deletions src/app/(app)/tickets/utils/is-resolved-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { TicketStatusDefinition } from '../statuses/types/ticket-statuses.types';
import { TICKET_STATUS_KIND } from './ticket-statistics';

/**
* True when `statusId` refers to a RESOLVED-kind status in `snapshot`.
*
* Single source of truth for "is this transition/edit resolving the ticket?",
* used to fire the `resolve_ticket_ticket_detail` activity event from every
* resolve entry point (the detail-view status changer and the Edit Ticket form).
* Keyed on `kind`, not a hardcoded id/name, so custom-named resolved statuses
* ("Done", "Closed", …) are recognized. `availableTransitions` carries no
* `kind`, so callers pass the full statuses `snapshot`.
*/
export function isResolvedStatusId(
statusId: string | null | undefined,
snapshot: TicketStatusDefinition[] | undefined,
): boolean {
if (!statusId || !snapshot) return false;
return snapshot.find(s => s.id === statusId)?.kind === TICKET_STATUS_KIND.RESOLVED;
}
Loading