@agent-native/core 0.159.1 → 0.159.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +11 -6
  3. package/corpus/templates/clips/AGENTS.md +1 -0
  4. package/corpus/templates/clips/actions/lib/meeting-content.ts +15 -4
  5. package/corpus/templates/clips/actions/list-meetings.ts +179 -42
  6. package/corpus/templates/clips/actions/search-meetings.ts +277 -0
  7. package/corpus/templates/clips/app/components/meetings/agenda-card.tsx +273 -0
  8. package/corpus/templates/clips/app/components/meetings/day-grouped-card.tsx +113 -0
  9. package/corpus/templates/clips/app/components/meetings/meeting-history-row.tsx +117 -0
  10. package/corpus/templates/clips/app/hooks/use-navigation-state.ts +12 -2
  11. package/corpus/templates/clips/app/i18n/en-US.ts +7 -2
  12. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +263 -357
  13. package/corpus/templates/clips/changelog/2026-08-14-meetings-history-is-searchable-again.md +6 -0
  14. package/corpus/templates/clips/desktop/design-refs/granola-ux.md +17 -0
  15. package/corpus/templates/content/actions/_database-source-utils.ts +29 -2
  16. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +11 -11
  17. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +11 -50
  18. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +57 -20
  19. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +30 -0
  20. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +204 -51
  21. package/corpus/templates/content/app/global.css +4 -1
  22. package/corpus/templates/content/app/i18n-data.ts +30 -0
  23. package/corpus/templates/content/changelog/2026-08-12-toggle-blocks-now-follow-notion-style-enter-and-shift-tab-be.md +6 -0
  24. package/corpus/templates/content/docs/solutions/2026-08-12-toggle-summary-focus-persistence-shape.md +733 -0
  25. package/corpus/templates/content/shared/builder-mdx.ts +44 -4
  26. package/corpus/templates/slides/actions/get-layout-overflows.ts +65 -2
  27. package/dist/deploy/build.d.ts +6 -4
  28. package/dist/deploy/build.js +27 -11
  29. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  30. package/dist/notifications/routes.d.ts +3 -3
  31. package/dist/observability/routes.d.ts +6 -6
  32. package/dist/secrets/routes.d.ts +3 -3
  33. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  34. package/dist/server/embed-route.js +6 -1
  35. package/dist/server/embed-session.d.ts +4 -1
  36. package/dist/server/embed-session.js +40 -1
  37. package/package.json +2 -2
  38. package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +0 -333
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Search meetings by title, AI summary, user notes, attendee, or the linked
3
+ * recording's transcript.
4
+ *
5
+ * `list-meetings` is a lifecycle list — it answers "what happened around this
6
+ * date". This answers "which call was the one where we talked about X", which
7
+ * is the only way to reach a meeting older than the visible history window.
8
+ *
9
+ * Scope: every source is joined through `accessFilter(meetings, meetingShares)`
10
+ * in the query itself, so a transcript match can never surface a meeting the
11
+ * caller cannot already open.
12
+ */
13
+
14
+ import { defineAction } from "@agent-native/core";
15
+ import { accessFilter } from "@agent-native/core/sharing";
16
+ import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
17
+ import { z } from "zod";
18
+
19
+ import { getDb, schema } from "../server/db/index.js";
20
+ import { booleanParam } from "./lib/cli-params.js";
21
+ import { buildCaseInsensitiveSearchPattern } from "./search-recordings-utils.js";
22
+
23
+ const SNIPPET_RADIUS = 90;
24
+
25
+ /** Columns every match source selects, so results have one uniform shape. */
26
+ const MEETING_COLUMNS = {
27
+ id: schema.meetings.id,
28
+ title: schema.meetings.title,
29
+ scheduledStart: schema.meetings.scheduledStart,
30
+ scheduledEnd: schema.meetings.scheduledEnd,
31
+ actualStart: schema.meetings.actualStart,
32
+ actualEnd: schema.meetings.actualEnd,
33
+ createdAt: schema.meetings.createdAt,
34
+ recordingId: schema.meetings.recordingId,
35
+ transcriptStatus: schema.meetings.transcriptStatus,
36
+ summaryMd: schema.meetings.summaryMd,
37
+ userNotesMd: schema.meetings.userNotesMd,
38
+ source: schema.meetings.source,
39
+ platform: schema.meetings.platform,
40
+ trashedAt: schema.meetings.trashedAt,
41
+ } as const;
42
+
43
+ type MeetingRow = Pick<
44
+ typeof schema.meetings.$inferSelect,
45
+ keyof typeof MEETING_COLUMNS
46
+ >;
47
+
48
+ type MeetingMatchType =
49
+ | "title"
50
+ | "summary"
51
+ | "notes"
52
+ | "participant"
53
+ | "transcript";
54
+
55
+ /** Most specific match wins when one meeting matches several ways. */
56
+ const MATCH_PRECEDENCE: MeetingMatchType[] = [
57
+ "transcript",
58
+ "summary",
59
+ "notes",
60
+ "participant",
61
+ "title",
62
+ ];
63
+
64
+ function buildSnippet(
65
+ text: string | null | undefined,
66
+ query: string,
67
+ ): string | null {
68
+ if (!text || !query) return null;
69
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
70
+ if (idx === -1) return null;
71
+ const start = Math.max(0, idx - SNIPPET_RADIUS);
72
+ const end = Math.min(text.length, idx + query.length + SNIPPET_RADIUS);
73
+ const prefix = start > 0 ? "…" : "";
74
+ const suffix = end < text.length ? "…" : "";
75
+ return `${prefix}${text.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
76
+ }
77
+
78
+ export default defineAction({
79
+ description:
80
+ "Search meetings by title, AI summary, user notes, attendee name/email, or the transcript of the linked recording. Returns each match with a snippet showing why it matched. Use this to find an older call by what was said in it — list-meetings only filters by date.",
81
+ schema: z.object({
82
+ query: z.string().min(1).describe("Search text"),
83
+ limit: z.coerce.number().int().min(1).max(100).default(30),
84
+ includeTrashed: booleanParam
85
+ .default(false)
86
+ .describe("Include meetings that have been moved to trash."),
87
+ }),
88
+ http: { method: "GET" },
89
+ run: async (args) => {
90
+ const db = getDb();
91
+ const pattern = buildCaseInsensitiveSearchPattern(args.query);
92
+
93
+ const visible = () => {
94
+ const clauses = [accessFilter(schema.meetings, schema.meetingShares)];
95
+ if (!args.includeTrashed) {
96
+ clauses.push(isNull(schema.meetings.trashedAt));
97
+ }
98
+ return and(...clauses);
99
+ };
100
+
101
+ // Shared with list-meetings' non-forward-looking sort: most-recent-first,
102
+ // falling back through actualStart -> scheduledStart -> createdAt. Applied
103
+ // as an ORDER BY before every LIMIT below, so a source with more matches
104
+ // than `limit` truncates to its most recent rows instead of an arbitrary
105
+ // DB-chosen subset that could skip the meeting the user is actually after.
106
+ const recencyExpr = sql`COALESCE(${schema.meetings.actualStart}, ${schema.meetings.scheduledStart}, ${schema.meetings.createdAt})`;
107
+ const recencyOrder = desc(recencyExpr);
108
+
109
+ const [ownRows, participantMeetingIds, transcriptRows] = await Promise.all([
110
+ db
111
+ .select(MEETING_COLUMNS)
112
+ .from(schema.meetings)
113
+ .where(
114
+ and(
115
+ visible(),
116
+ sql`(lower(${schema.meetings.title}) LIKE ${pattern} ESCAPE '\\' OR lower(${schema.meetings.summaryMd}) LIKE ${pattern} ESCAPE '\\' OR lower(${schema.meetings.userNotesMd}) LIKE ${pattern} ESCAPE '\\')`,
117
+ ),
118
+ )
119
+ .orderBy(recencyOrder)
120
+ .limit(args.limit),
121
+ // meeting_participants has one row per attendee, so limiting attendee
122
+ // rows directly could let one large meeting's matching attendees fill
123
+ // the whole quota and hide every other matching meeting. Limit distinct
124
+ // meeting ids instead, then fetch their participant rows unbounded.
125
+ //
126
+ // PostgreSQL requires every ORDER BY expression on a SELECT DISTINCT to
127
+ // also appear in the select list (SQLite has no such rule, which is why
128
+ // this passed locally against SQLite but fails on Postgres in prod) —
129
+ // so `recency` is selected here as its own column, not just ordered by.
130
+ // It's safe to include: every row for a given meetingId shares the same
131
+ // recency value (it comes from the joined meetings row), so adding it
132
+ // to the DISTINCT projection can't create spurious per-meeting duplicates.
133
+ db
134
+ .selectDistinct({
135
+ meetingId: schema.meetingParticipants.meetingId,
136
+ recency: recencyExpr,
137
+ })
138
+ .from(schema.meetingParticipants)
139
+ .innerJoin(
140
+ schema.meetings,
141
+ eq(schema.meetingParticipants.meetingId, schema.meetings.id),
142
+ )
143
+ .where(
144
+ and(
145
+ visible(),
146
+ sql`(lower(${schema.meetingParticipants.email}) LIKE ${pattern} ESCAPE '\\' OR lower(${schema.meetingParticipants.name}) LIKE ${pattern} ESCAPE '\\')`,
147
+ ),
148
+ )
149
+ .orderBy(recencyOrder)
150
+ .limit(args.limit),
151
+ db
152
+ .select({
153
+ ...MEETING_COLUMNS,
154
+ fullText: schema.recordingTranscripts.fullText,
155
+ })
156
+ .from(schema.meetings)
157
+ .innerJoin(
158
+ schema.recordingTranscripts,
159
+ eq(
160
+ schema.meetings.recordingId,
161
+ schema.recordingTranscripts.recordingId,
162
+ ),
163
+ )
164
+ .where(
165
+ and(
166
+ visible(),
167
+ sql`lower(${schema.recordingTranscripts.fullText}) LIKE ${pattern} ESCAPE '\\'`,
168
+ ),
169
+ )
170
+ .orderBy(recencyOrder)
171
+ .limit(args.limit),
172
+ ]);
173
+
174
+ const participantMeetingIdList = participantMeetingIds.map(
175
+ (row) => row.meetingId,
176
+ );
177
+ const participantRows = participantMeetingIdList.length
178
+ ? await db
179
+ .select({
180
+ ...MEETING_COLUMNS,
181
+ participantName: schema.meetingParticipants.name,
182
+ participantEmail: schema.meetingParticipants.email,
183
+ })
184
+ .from(schema.meetingParticipants)
185
+ .innerJoin(
186
+ schema.meetings,
187
+ eq(schema.meetingParticipants.meetingId, schema.meetings.id),
188
+ )
189
+ .where(
190
+ and(
191
+ inArray(
192
+ schema.meetingParticipants.meetingId,
193
+ participantMeetingIdList,
194
+ ),
195
+ sql`(lower(${schema.meetingParticipants.email}) LIKE ${pattern} ESCAPE '\\' OR lower(${schema.meetingParticipants.name}) LIKE ${pattern} ESCAPE '\\')`,
196
+ ),
197
+ )
198
+ : [];
199
+
200
+ const merged = new Map<
201
+ string,
202
+ MeetingRow & { matchType: MeetingMatchType; snippet: string | null }
203
+ >();
204
+
205
+ const record = (
206
+ meeting: MeetingRow,
207
+ matchType: MeetingMatchType,
208
+ snippet: string | null,
209
+ ) => {
210
+ const existing = merged.get(meeting.id);
211
+ if (
212
+ existing &&
213
+ MATCH_PRECEDENCE.indexOf(existing.matchType) <=
214
+ MATCH_PRECEDENCE.indexOf(matchType)
215
+ ) {
216
+ return;
217
+ }
218
+ merged.set(meeting.id, { ...meeting, matchType, snippet });
219
+ };
220
+
221
+ for (const { fullText, ...meeting } of transcriptRows) {
222
+ record(meeting, "transcript", buildSnippet(fullText, args.query));
223
+ }
224
+ for (const row of ownRows) {
225
+ const summarySnippet = buildSnippet(row.summaryMd, args.query);
226
+ if (summarySnippet) {
227
+ record(row, "summary", summarySnippet);
228
+ continue;
229
+ }
230
+ const notesSnippet = buildSnippet(row.userNotesMd, args.query);
231
+ if (notesSnippet) {
232
+ record(row, "notes", notesSnippet);
233
+ continue;
234
+ }
235
+ record(row, "title", null);
236
+ }
237
+ for (const row of participantRows) {
238
+ const { participantName, participantEmail, ...meeting } = row;
239
+ record(
240
+ meeting,
241
+ "participant",
242
+ participantName?.trim() || participantEmail,
243
+ );
244
+ }
245
+
246
+ const ids = Array.from(merged.keys());
247
+ const participants = ids.length
248
+ ? await db
249
+ .select()
250
+ .from(schema.meetingParticipants)
251
+ .where(inArray(schema.meetingParticipants.meetingId, ids))
252
+ : [];
253
+ const participantsByMeeting = new Map<string, typeof participants>();
254
+ for (const participant of participants) {
255
+ const list = participantsByMeeting.get(participant.meetingId) ?? [];
256
+ list.push(participant);
257
+ participantsByMeeting.set(participant.meetingId, list);
258
+ }
259
+
260
+ const meetings = Array.from(merged.values())
261
+ .map((meeting) => ({
262
+ ...meeting,
263
+ participants: participantsByMeeting.get(meeting.id) ?? [],
264
+ }))
265
+ .sort((a, b) => {
266
+ const aStart = Date.parse(a.actualStart ?? a.scheduledStart ?? "");
267
+ const bStart = Date.parse(b.actualStart ?? b.scheduledStart ?? "");
268
+ return (
269
+ (Number.isNaN(bStart) ? 0 : bStart) -
270
+ (Number.isNaN(aStart) ? 0 : aStart)
271
+ );
272
+ })
273
+ .slice(0, args.limit);
274
+
275
+ return { meetings, query: args.query };
276
+ },
277
+ });
@@ -0,0 +1,273 @@
1
+ /**
2
+ * <AgendaCard /> — the Meetings tab's rolling agenda.
3
+ *
4
+ * Independent per-meeting cards, not a grid of tiles — see
5
+ * `desktop/design-refs/granola-ux.md` §2. It is a Zoom-style rolling window
6
+ * rather than a strict future list: `view=agenda` reaches 24h back, so the
7
+ * calls you already had today stay on your day, with a "now" marker between
8
+ * what has happened and what has not. Anything older falls out to the Past tab.
9
+ *
10
+ * Each meeting renders as its own bordered Card rather than a shared row
11
+ * inside one frame — per @shawnmcclelland's review, the "now" marker only
12
+ * reads cleanly as a divider when it sits between two independent elements;
13
+ * inside a single shared card (our first pass) it visually collided with the
14
+ * day-number column. That independence is also why the marker is confined to
15
+ * a single day (see `nowMarkerIndex`): between the last meeting of a mostly-
16
+ * finished day and the first of tomorrow is a day boundary, not a "now" — the
17
+ * day header already marks that transition, so a second marker there just
18
+ * reads as one more ambiguous boundary line (the "limbo" feel Shawn called
19
+ * out from Zoom's own equivalent).
20
+ *
21
+ * Recording is a desktop gesture, so a row never offers a web "record" button
22
+ * that cannot work; the imminent row offers Join and Open notes instead.
23
+ */
24
+ import { useT } from "@agent-native/core/client/i18n";
25
+ import { IconExternalLink } from "@tabler/icons-react";
26
+ import { Fragment } from "react";
27
+ import { NavLink } from "react-router";
28
+
29
+ import { Button } from "@/components/ui/button";
30
+ import { Card } from "@/components/ui/card";
31
+ import { cn } from "@/lib/utils";
32
+
33
+ import { AttendeeStack, type AttendeeStackParticipant } from "./attendee-stack";
34
+ import { groupByCalendarDay } from "./day-grouped-card";
35
+ import { DayHeader, formatDayLabel } from "./day-header";
36
+
37
+ export interface AgendaMeeting {
38
+ id: string;
39
+ title: string;
40
+ scheduledStart: string;
41
+ scheduledEnd?: string | null;
42
+ actualStart?: string | null;
43
+ actualEnd?: string | null;
44
+ joinUrl?: string | null;
45
+ participants?: AttendeeStackParticipant[];
46
+ }
47
+
48
+ type Translate = (key: string, params?: Record<string, unknown>) => string;
49
+
50
+ /**
51
+ * Whether a meeting has actually finished. Agenda intentionally keeps
52
+ * already-ended meetings (see module doc), so "is this over" has to be its
53
+ * own check — `relativeStartLabel`'s `soon` only looks at scheduledStart and
54
+ * stays true for up to 2h after start regardless of whether the call ended.
55
+ */
56
+ export function meetingHasEnded(
57
+ meeting: Pick<AgendaMeeting, "actualEnd" | "scheduledEnd" | "scheduledStart">,
58
+ nowMs: number = Date.now(),
59
+ ): boolean {
60
+ const endMs = Date.parse(
61
+ meeting.actualEnd ?? meeting.scheduledEnd ?? meeting.scheduledStart,
62
+ );
63
+ return !Number.isNaN(endMs) && endMs < nowMs;
64
+ }
65
+
66
+ function formatTime(iso?: string | null): string {
67
+ if (!iso) return "";
68
+ const d = new Date(iso);
69
+ if (Number.isNaN(d.getTime())) return "";
70
+ return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
71
+ }
72
+
73
+ /**
74
+ * Index of the first meeting that has not finished yet — where the "now"
75
+ * marker goes. Returns -1 when every meeting is still ahead, so a day that
76
+ * hasn't started yet doesn't get a marker pinned above its first row.
77
+ */
78
+ export function nowMarkerIndex(
79
+ meetings: AgendaMeeting[],
80
+ nowMs: number,
81
+ ): number {
82
+ const firstUnfinished = meetings.findIndex((m) => {
83
+ const endMs = Date.parse(m.actualEnd ?? m.scheduledEnd ?? m.scheduledStart);
84
+ return Number.isNaN(endMs) || endMs >= nowMs;
85
+ });
86
+ return firstUnfinished > 0 ? firstUnfinished : -1;
87
+ }
88
+
89
+ /** Human "now" / "in 5 min" / "in 2 hr" label for an upcoming row. */
90
+ export function relativeStartLabel(
91
+ iso: string,
92
+ t: Translate,
93
+ ): { text: string; soon: boolean } {
94
+ const start = Date.parse(iso);
95
+ if (Number.isNaN(start)) return { text: "", soon: false };
96
+ const diffMin = Math.round((start - Date.now()) / 60000);
97
+ if (diffMin <= 0 && diffMin > -120)
98
+ return { text: t("meetingCard.now"), soon: true };
99
+ if (diffMin <= 0) return { text: t("meetingCard.started"), soon: false };
100
+ if (diffMin < 60)
101
+ return {
102
+ text: t("meetingCard.inMinutes", { count: diffMin }),
103
+ soon: diffMin <= 5,
104
+ };
105
+ const hrs = Math.round(diffMin / 60);
106
+ return { text: t("meetingCard.inHours", { count: hrs }), soon: false };
107
+ }
108
+
109
+ function AgendaRow({ meeting }: { meeting: AgendaMeeting }) {
110
+ const t = useT();
111
+ const isLive = !!(meeting.actualStart && !meeting.actualEnd);
112
+ const hasEnded = meetingHasEnded(meeting);
113
+ const { text: whenText, soon } = relativeStartLabel(
114
+ meeting.scheduledStart,
115
+ t,
116
+ );
117
+ const active = isLive || (soon && !hasEnded);
118
+ const start = formatTime(meeting.scheduledStart);
119
+ const end = formatTime(meeting.scheduledEnd);
120
+
121
+ return (
122
+ // Wraps rather than compressing: at ~375px the title, avatars and both
123
+ // buttons cannot share a line, and a nowrap row silently slides the
124
+ // buttons on top of the title instead of pushing them down.
125
+ <Card className="flex flex-wrap items-center gap-x-3 gap-y-2 p-3">
126
+ <div className="flex min-w-0 flex-1 basis-48 items-center gap-3">
127
+ <span
128
+ aria-hidden
129
+ className={cn(
130
+ "w-0.5 self-stretch rounded-full",
131
+ active ? "bg-foreground/40" : "bg-border",
132
+ )}
133
+ />
134
+ <NavLink
135
+ to={`/meetings/${meeting.id}`}
136
+ className="min-w-0 flex-1 rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
137
+ >
138
+ <div className="truncate text-sm font-medium text-foreground">
139
+ {meeting.title || t("meetingDetail.untitledMeeting")}
140
+ </div>
141
+ <div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs tabular-nums text-muted-foreground">
142
+ {isLive ? (
143
+ <span className="inline-flex items-center gap-1 font-medium text-destructive">
144
+ <span className="relative flex h-1.5 w-1.5">
145
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-60" />
146
+ <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-destructive" />
147
+ </span>
148
+ {t("meetingCard.live")}
149
+ </span>
150
+ ) : whenText && !hasEnded ? (
151
+ <span className={cn(soon && "font-medium text-foreground")}>
152
+ {whenText}
153
+ </span>
154
+ ) : null}
155
+ {(isLive || whenText) && start ? <span>·</span> : null}
156
+ {start ? <span>{end ? `${start} – ${end}` : start}</span> : null}
157
+ </div>
158
+ </NavLink>
159
+ </div>
160
+ <div className="flex shrink-0 items-center gap-2">
161
+ <span className="hidden sm:inline-flex">
162
+ <AttendeeStack participants={meeting.participants ?? []} size="xs" />
163
+ </span>
164
+ {active ? (
165
+ <div className="flex shrink-0 items-center gap-1.5">
166
+ {meeting.joinUrl ? (
167
+ <Button
168
+ asChild
169
+ size="sm"
170
+ variant="outline"
171
+ className="h-7 gap-1 px-2 text-xs cursor-pointer"
172
+ >
173
+ <a
174
+ href={meeting.joinUrl}
175
+ target="_blank"
176
+ rel="noopener noreferrer"
177
+ >
178
+ <IconExternalLink className="h-3.5 w-3.5" />
179
+ {t("meetingCard.join")}
180
+ </a>
181
+ </Button>
182
+ ) : null}
183
+ <Button
184
+ asChild
185
+ size="sm"
186
+ className="h-7 px-2.5 text-xs cursor-pointer"
187
+ >
188
+ <NavLink to={`/meetings/${meeting.id}`}>
189
+ {t("meetingCard.openNotes")}
190
+ </NavLink>
191
+ </Button>
192
+ </div>
193
+ ) : null}
194
+ </div>
195
+ </Card>
196
+ );
197
+ }
198
+
199
+ /** Zoom's orange current-time rule: what is behind you, and what is not. */
200
+ function NowMarker() {
201
+ const t = useT();
202
+ return (
203
+ <div className="my-1 flex items-center gap-2" aria-hidden>
204
+ <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
205
+ <span className="text-[10px] font-medium uppercase tracking-[0.08em] text-primary">
206
+ {t("meetingsRoute.now", { defaultValue: "Now" })}
207
+ </span>
208
+ <span className="h-px flex-1 bg-primary/40" />
209
+ </div>
210
+ );
211
+ }
212
+
213
+ export function AgendaCard({ meetings }: { meetings: AgendaMeeting[] }) {
214
+ if (meetings.length === 0) return null;
215
+ const days = groupByCalendarDay(
216
+ meetings,
217
+ (m) => m.scheduledStart,
218
+ (a, b) => Date.parse(a.scheduledStart) - Date.parse(b.scheduledStart) || 0,
219
+ );
220
+ // The marker is computed over the flat, already-sorted list, then matched
221
+ // back per day below — a day group cannot know how many meetings preceded
222
+ // it on its own.
223
+ const markerIndex = nowMarkerIndex(meetings, Date.now());
224
+
225
+ let flatIndex = 0;
226
+ return (
227
+ <div className="space-y-6">
228
+ {days.map(([key, items]) => {
229
+ const dayStartIndex = flatIndex;
230
+ flatIndex += items.length;
231
+ // Restrict the marker to strictly within this day's own rows. At
232
+ // `dayStartIndex` exactly, the marker would fall right where the day
233
+ // header already sits — a day boundary, not a live current-time mark —
234
+ // so it renders as a second, redundant, ambiguous divider. See the
235
+ // module doc.
236
+ // Boolean expression, not a visible string.
237
+ const withinThisDay =
238
+ markerIndex > dayStartIndex && markerIndex < flatIndex; // i18n-ignore
239
+ const dayMarkerIndex = withinThisDay ? markerIndex : -1;
240
+ return (
241
+ <div key={key} className="space-y-2">
242
+ <DayHeader label={formatDayLabel(items[0]!.scheduledStart)} />
243
+ {items.map((m, i) => (
244
+ <Fragment key={m.id}>
245
+ {dayStartIndex + i === dayMarkerIndex ? <NowMarker /> : null}
246
+ <AgendaRow meeting={m} />
247
+ </Fragment>
248
+ ))}
249
+ </div>
250
+ );
251
+ })}
252
+ </div>
253
+ );
254
+ }
255
+
256
+ export function AgendaCardSkeleton() {
257
+ return (
258
+ <div className="space-y-2">
259
+ <div className="h-3 w-16 animate-pulse rounded bg-muted/70" />
260
+ {Array.from({ length: 2 }).map((_, i) => (
261
+ <div
262
+ key={i}
263
+ className="flex items-center gap-3 rounded-lg border border-border bg-card p-3"
264
+ >
265
+ <div className="min-w-0 flex-1 space-y-2">
266
+ <div className="h-4 w-2/5 animate-pulse rounded bg-muted" />
267
+ <div className="h-3 w-24 animate-pulse rounded bg-muted/70" />
268
+ </div>
269
+ </div>
270
+ ))}
271
+ </div>
272
+ );
273
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * <DayGroupedCard /> — the shared day-column shell behind both the Agenda
3
+ * card and the Past history list. Per @shawnmcclelland's review on #2887:
4
+ * Past used a bare `DayHeader` label over a flat row list while Agenda used
5
+ * a bordered card with a day-number column; reusing one shell keeps the two
6
+ * tabs reading as the same surface instead of two different UI languages.
7
+ */
8
+ import type { ReactNode } from "react";
9
+ import { Fragment } from "react";
10
+
11
+ import { Card, CardContent } from "@/components/ui/card";
12
+
13
+ export interface DayParts {
14
+ dayNumber: string;
15
+ month: string;
16
+ weekday: string;
17
+ }
18
+
19
+ export function dayParts(iso: string): DayParts {
20
+ const d = new Date(iso);
21
+ if (Number.isNaN(d.getTime())) {
22
+ return { dayNumber: "", month: "", weekday: "" };
23
+ }
24
+ return {
25
+ dayNumber: d.toLocaleDateString([], { day: "numeric" }),
26
+ month: d.toLocaleDateString([], { month: "long" }),
27
+ weekday: d.toLocaleDateString([], { weekday: "short" }),
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Groups items by calendar day (local time) using a caller-supplied ISO
33
+ * getter, then optionally re-sorts each day's items by `sortWithin` — a group
34
+ * cannot trust incoming order (see `historyTimestampMs` in the meetings
35
+ * route), so callers that need a specific within-day order pass it explicitly
36
+ * rather than assuming the input array was already sorted that way.
37
+ */
38
+ export function groupByCalendarDay<T>(
39
+ items: T[],
40
+ getIso: (item: T) => string,
41
+ sortWithin?: (a: T, b: T) => number,
42
+ ): Array<[string, T[]]> {
43
+ const groups = new Map<string, T[]>();
44
+ for (const item of items) {
45
+ const d = new Date(getIso(item));
46
+ const iso = getIso(item);
47
+ const key = Number.isNaN(d.getTime())
48
+ ? iso
49
+ : `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
50
+ const list = groups.get(key) ?? [];
51
+ list.push(item);
52
+ groups.set(key, list);
53
+ }
54
+ if (sortWithin) {
55
+ for (const list of groups.values()) list.sort(sortWithin);
56
+ }
57
+ return Array.from(groups.entries());
58
+ }
59
+
60
+ export function DayGroupedCard<T extends { id: string }>({
61
+ groups,
62
+ getIso,
63
+ renderRow,
64
+ markerIndex = -1,
65
+ renderMarker,
66
+ }: {
67
+ groups: Array<[string, T[]]>;
68
+ getIso: (item: T) => string;
69
+ renderRow: (item: T) => ReactNode;
70
+ /** Flat index (across all groups) to render `renderMarker` above. -1 = none. */
71
+ markerIndex?: number;
72
+ renderMarker?: () => ReactNode;
73
+ }) {
74
+ let flatIndex = 0;
75
+ return (
76
+ <Card>
77
+ {/* py-4 (not py-3) and a full-width divider, per review feedback that a
78
+ hairline alone read as one continuous entry rather than separate days. */}
79
+ <CardContent className="divide-y divide-border p-0">
80
+ {groups.map(([key, items]) => {
81
+ const { dayNumber, month, weekday } = dayParts(getIso(items[0]!));
82
+ const dayStartIndex = flatIndex;
83
+ flatIndex += items.length;
84
+ return (
85
+ <div key={key} className="flex gap-4 px-4 py-4">
86
+ <div className="w-14 shrink-0">
87
+ <div className="text-xl font-semibold leading-none tabular-nums text-foreground">
88
+ {dayNumber}
89
+ </div>
90
+ <div className="mt-1 text-[11px] leading-tight text-muted-foreground">
91
+ {month}
92
+ </div>
93
+ <div className="text-[11px] leading-tight text-muted-foreground">
94
+ {weekday}
95
+ </div>
96
+ </div>
97
+ <div className="min-w-0 flex-1 space-y-2.5">
98
+ {items.map((item, i) => (
99
+ <Fragment key={item.id}>
100
+ {renderMarker && dayStartIndex + i === markerIndex
101
+ ? renderMarker()
102
+ : null}
103
+ {renderRow(item)}
104
+ </Fragment>
105
+ ))}
106
+ </div>
107
+ </div>
108
+ );
109
+ })}
110
+ </CardContent>
111
+ </Card>
112
+ );
113
+ }