From 75f9bb85152b7b33ee702b49e521e608a59f2dfc Mon Sep 17 00:00:00 2001 From: Aslah Date: Thu, 8 Oct 2026 12:57:56 +0530 Subject: [PATCH 1/3] feat: implement meetings view component with filtering, sorting, and search capabilities --- renderer/components/Meetings.jsx | 376 ++++++++++++++++++++++++++----- renderer/lib/meetingFilters.js | 208 +++++++++++++++++ tests/meetingFilters.test.js | 278 +++++++++++++++++++++++ 3 files changed, 808 insertions(+), 54 deletions(-) create mode 100644 renderer/lib/meetingFilters.js create mode 100644 tests/meetingFilters.test.js diff --git a/renderer/components/Meetings.jsx b/renderer/components/Meetings.jsx index 71ac1b1..282a9b4 100644 --- a/renderer/components/Meetings.jsx +++ b/renderer/components/Meetings.jsx @@ -1,32 +1,36 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react'; -import { RefreshCw, Upload, ClipboardList, Search, X, Mic } from 'lucide-react'; +import { + RefreshCw, Upload, ClipboardList, Search, X, Mic, + ArrowUpDown, Calendar, ChevronDown, ChevronUp, SlidersHorizontal, +} from 'lucide-react'; import { useApp } from '../lib/app-context.js'; import PasteTranscriptModal from './PasteTranscriptModal.jsx'; import { SessionList, SessionListSkeleton } from './SessionCard.jsx'; import { PageHeader, IconButton, EmptyState, Skeleton, StatusDot } from './ui/index.jsx'; -import { sessionDisplayTitle } from '../lib/format.js'; -import { isProcessing } from '../lib/status.js'; import { useDelayedFlag, useSessionActions, SKELETON_DELAY_MS } from '../lib/hooks.js'; import { useScrollMemory, useRememberedState } from '../lib/scrollMemory.js'; +import { + STATUS_FILTERS, + DURATION_FILTERS, + PLATFORM_FILTERS, + CONTENT_FILTERS, + SORT_OPTIONS, + filterAndSort, + statusCounts, + hasActiveFilters, +} from '../lib/meetingFilters.js'; -const FILTERS = [ - { key: 'all', label: 'All', test: () => true }, - { key: 'attention', label: 'Needs attention', test: (s) => s.status === 'error' }, - { key: 'processing',label: 'Processing', test: (s) => isProcessing(s.status) }, - { key: 'notion', label: 'In Notion', test: (s) => !!s.notion_page_url }, -]; - -function matchesQuery(session, query) { - if (!query) return true; - const haystack = [ - sessionDisplayTitle(session), - session.notes?.meeting_title, - session.notes?.summary, - session.meeting_url, - ].filter((v) => typeof v === 'string').join(' ').toLowerCase(); - return haystack.includes(query); +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Format a native input date value for display. */ +function friendlyDate(isoDate) { + if (!isoDate) return ''; + const d = new Date(isoDate + 'T00:00:00'); // local midnight + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } +// ── Sub-components ─────────────────────────────────────────────────────────── + function MeetingsSkeleton() { return (
@@ -42,32 +46,170 @@ function MeetingsSkeleton() { ); } +/** Inline date range picker — two native date inputs with clear. */ +function DateRangePicker({ dateFrom, dateTo, onChange }) { + const hasDates = dateFrom || dateTo; + + return ( +
+
+ ); +} + +/** A select-style dropdown built from pills so it matches the design system. */ +function FilterSelect({ value, options, onChange, label }) { + return ( +
+ + +
+ ); +} + +/** Toggleable content-filter chips. */ +function ContentChips({ active, onChange }) { + return ( +
+ {CONTENT_FILTERS.map((cf) => { + const isActive = active.includes(cf.key); + return ( + + ); + })} +
+ ); +} + +/** Active-filter summary strip shown when the "More filters" panel is collapsed. */ +function ActiveFilterSummary({ opts, onClear }) { + const tags = []; + if (opts.dateFrom || opts.dateTo) { + const parts = []; + if (opts.dateFrom) parts.push(`from ${friendlyDate(opts.dateFrom)}`); + if (opts.dateTo) parts.push(`to ${friendlyDate(opts.dateTo)}`); + tags.push({ key: 'dates', label: parts.join(' '), clear: () => onClear('dates') }); + } + if (opts.duration !== 'any') { + const d = DURATION_FILTERS.find((f) => f.key === opts.duration); + tags.push({ key: 'duration', label: d?.label || opts.duration, clear: () => onClear('duration') }); + } + if (opts.platform !== 'any') { + const p = PLATFORM_FILTERS.find((f) => f.key === opts.platform); + tags.push({ key: 'platform', label: p?.label || opts.platform, clear: () => onClear('platform') }); + } + for (const ck of (opts.content || [])) { + const cf = CONTENT_FILTERS.find((c) => c.key === ck); + if (cf) tags.push({ key: ck, label: cf.label, clear: () => onClear(ck) }); + } + if (opts.sort !== 'newest') { + const s = SORT_OPTIONS.find((o) => o.key === opts.sort); + tags.push({ key: 'sort', label: s?.label || opts.sort, clear: () => onClear('sort') }); + } + + if (tags.length === 0) return null; + + return ( +
+ {tags.map((t) => ( + + ))} +
+ ); +} + +// ── Main component ─────────────────────────────────────────────────────────── + export default function Meetings({ onOpenSession }) { const { sessions, refreshSessions, startRecording, isRecording, sessionsLoading, sessionsError } = useApp(); const { handleUploadAudio } = useSessionActions(); const showSkeleton = useDelayedFlag(sessionsLoading, SKELETON_DELAY_MS); - // Remembered so Back from a meeting returns to the same filtered list (and scroll spot). - const [query, setQuery] = useRememberedState('meetings:query', ''); - const [filter, setFilter] = useRememberedState('meetings:filter', 'all'); const scrollRef = useScrollMemory('meetings'); const [showPasteModal, setShowPasteModal] = useState(false); const [refreshing, setRefreshing] = useState(false); + // Remembered so Back from a meeting returns to the same filtered list. + const [query, setQuery] = useRememberedState('meetings:query', ''); + const [status, setStatus] = useRememberedState('meetings:filter', 'all'); + const [duration, setDuration] = useRememberedState('meetings:duration', 'any'); + const [platform, setPlatform] = useRememberedState('meetings:platform', 'any'); + const [dateFrom, setDateFrom] = useRememberedState('meetings:dateFrom', ''); + const [dateTo, setDateTo] = useRememberedState('meetings:dateTo', ''); + const [content, setContent] = useRememberedState('meetings:content', []); + const [sort, setSort] = useRememberedState('meetings:sort', 'newest'); + const [filtersOpen, setFiltersOpen] = useRememberedState('meetings:filtersOpen', false); + useEffect(() => { refreshSessions(); }, [refreshSessions]); - const counts = useMemo(() => { - const out = {}; - for (const f of FILTERS) out[f.key] = sessions.filter(f.test).length; - return out; - }, [sessions]); + const counts = useMemo(() => statusCounts(sessions), [sessions]); - const visible = useMemo(() => { - const q = query.trim().toLowerCase(); - const active = FILTERS.find((f) => f.key === filter) || FILTERS[0]; - return sessions.filter((s) => active.test(s) && matchesQuery(s, q)); - }, [sessions, query, filter]); + const filterOpts = useMemo( + () => ({ query, status, duration, platform, dateFrom, dateTo, content, sort }), + [query, status, duration, platform, dateFrom, dateTo, content, sort], + ); + + const visible = useMemo(() => filterAndSort(sessions, filterOpts), [sessions, filterOpts]); + + const isFiltered = useMemo(() => hasActiveFilters(filterOpts), [filterOpts]); const openPaste = useCallback(() => setShowPasteModal(true), []); const closePaste = useCallback(() => setShowPasteModal(false), []); @@ -81,11 +223,37 @@ export default function Meetings({ onOpenSession }) { } }; - const clearFilters = () => { + const clearAllFilters = () => { setQuery(''); - setFilter('all'); + setStatus('all'); + setDuration('any'); + setPlatform('any'); + setDateFrom(''); + setDateTo(''); + setContent([]); + setSort('newest'); + }; + + const clearSingleFilter = (key) => { + if (key === 'dates') { setDateFrom(''); setDateTo(''); } + else if (key === 'duration') setDuration('any'); + else if (key === 'platform') setPlatform('any'); + else if (key === 'sort') setSort('newest'); + else if (CONTENT_FILTERS.some((c) => c.key === key)) { + setContent((prev) => prev.filter((k) => k !== key)); + } }; + // How many extra filters are active beyond search + status. + const extraFilterCount = [ + duration !== 'any', + platform !== 'any', + dateFrom !== '', + dateTo !== '', + sort !== 'newest', + ...content.map(() => true), + ].filter(Boolean).length; + if (sessionsLoading) { return showSkeleton ? :
; } @@ -148,8 +316,9 @@ export default function Meetings({ onOpenSession }) { ) ) : ( <> - {/* Search + filters */} -
+ {/* ── Search + status filters + controls ─────────────────────── */} +
+ {/* Search bar */}
-
- {FILTERS.map((f) => { - const active = filter === f.key; - return ( - - ); - })} + + {/* Sort selector */} +
+
+ + {/* Toggle extra filters */} +
+ {/* Status filter chips */} +
+ {STATUS_FILTERS.map((f) => { + const active = status === f.key; + return ( + + ); + })} +
+ + {/* ── Extended filters panel ──────────────────────────────────── */} + {filtersOpen && ( +
+ {/* Row 1: Date range */} +
+ Date + { + setDateFrom(df); + setDateTo(dt); + }} + /> +
+ + {/* Row 2: Duration + Platform */} +
+ Duration + + + Platform + +
+ + {/* Row 3: Content chips */} +
+ Content + +
+
+ )} + + {/* Active-filter summary (visible when panel is closed) */} + {!filtersOpen && ( +
+ +
+ )} + + {/* ── Results ────────────────────────────────────────────────── */} + {isFiltered && ( +
+

+ {visible.length} {visible.length === 1 ? 'meeting' : 'meetings'} found +

+ +
+ )} + {visible.length === 0 ? ( + } diff --git a/renderer/lib/meetingFilters.js b/renderer/lib/meetingFilters.js new file mode 100644 index 0000000..c5cff13 --- /dev/null +++ b/renderer/lib/meetingFilters.js @@ -0,0 +1,208 @@ +// Pure filter/sort logic for the Meetings page. +// Extracted so it stays testable without React or DOM dependencies. + +import { sessionDisplayTitle } from './format.js'; +import { isProcessing } from './status.js'; + +// ── Status filters ────────────────────────────────────────────────────────── + +export const STATUS_FILTERS = [ + { key: 'all', label: 'All', test: () => true }, + { key: 'attention', label: 'Needs attention', test: (s) => s.status === 'error' }, + { key: 'processing', label: 'Processing', test: (s) => isProcessing(s.status) }, + { key: 'notion', label: 'In Notion', test: (s) => !!s.notion_page_url }, +]; + +// ── Duration buckets ──────────────────────────────────────────────────────── + +function durationSeconds(session) { + if (session.duration_seconds != null) return session.duration_seconds; + if (session.started_at && session.ended_at) { + return Math.round((new Date(session.ended_at) - new Date(session.started_at)) / 1000); + } + return null; +} + +export const DURATION_FILTERS = [ + { key: 'any', label: 'Any length' }, + { key: 'short', label: '< 15 min', max: 15 * 60 }, + { key: 'medium', label: '15–60 min', min: 15 * 60, max: 60 * 60 }, + { key: 'long', label: '> 1 hour', min: 60 * 60 }, +]; + +function matchesDuration(session, durationKey) { + if (durationKey === 'any') return true; + const secs = durationSeconds(session); + if (secs == null) return false; + const bucket = DURATION_FILTERS.find((d) => d.key === durationKey); + if (!bucket) return true; + if (bucket.min != null && secs < bucket.min) return false; + if (bucket.max != null && secs >= bucket.max) return false; + return true; +} + +// ── Platform filter ───────────────────────────────────────────────────────── + +function detectPlatform(url) { + if (!url) return null; + if (url.includes('meet.google.com')) return 'meet'; + if (url.includes('zoom.us') || url.includes('zoom.com')) return 'zoom'; + if (url.includes('teams.microsoft.com') || url.includes('teams.live.com')) return 'teams'; + return 'other'; +} + +export const PLATFORM_FILTERS = [ + { key: 'any', label: 'Any platform' }, + { key: 'meet', label: 'Google Meet' }, + { key: 'zoom', label: 'Zoom' }, + { key: 'teams', label: 'Teams' }, +]; + +function matchesPlatform(session, platformKey) { + if (platformKey === 'any') return true; + return detectPlatform(session.meeting_url) === platformKey; +} + +// ── Date range ────────────────────────────────────────────────────────────── + +/** + * @param {string|null} dateFrom YYYY-MM-DD string or falsy + * @param {string|null} dateTo YYYY-MM-DD string or falsy + */ +function matchesDateRange(session, dateFrom, dateTo) { + if (!dateFrom && !dateTo) return true; + const ts = session.started_at; + if (!ts) return false; + // Compare as local-date strings (YYYY-MM-DD) so the filter boundaries + // align with the user's calendar day regardless of timezone. + const sessionDate = new Date(ts); + const yyyy = sessionDate.getFullYear(); + const mm = String(sessionDate.getMonth() + 1).padStart(2, '0'); + const dd = String(sessionDate.getDate()).padStart(2, '0'); + const localDate = `${yyyy}-${mm}-${dd}`; + if (dateFrom && localDate < dateFrom) return false; + if (dateTo && localDate > dateTo) return false; + return true; +} + +// ── Content filters ───────────────────────────────────────────────────────── + +export const CONTENT_FILTERS = [ + { key: 'hasActions', label: 'Has action items', test: (s) => (s.notes?.action_items?.length || 0) > 0 }, + { key: 'hasNotes', label: 'Has notes', test: (s) => !!(s.notes?.summary || s.notes?.meeting_title) }, +]; + +// ── Text search ───────────────────────────────────────────────────────────── + +function matchesQuery(session, query) { + if (!query) return true; + const haystack = [ + sessionDisplayTitle(session), + session.notes?.meeting_title, + session.notes?.summary, + session.meeting_url, + ].filter((v) => typeof v === 'string').join(' ').toLowerCase(); + return haystack.includes(query); +} + +// ── Sort options ──────────────────────────────────────────────────────────── + +export const SORT_OPTIONS = [ + { key: 'newest', label: 'Newest first' }, + { key: 'oldest', label: 'Oldest first' }, + { key: 'longest', label: 'Longest first' }, + { key: 'shortest', label: 'Shortest first' }, + { key: 'title', label: 'Title A–Z' }, +]; + +function sortSessions(sessions, sortKey) { + const arr = [...sessions]; + switch (sortKey) { + case 'oldest': + return arr.sort((a, b) => new Date(a.started_at || 0) - new Date(b.started_at || 0)); + case 'longest': + return arr.sort((a, b) => (durationSeconds(b) || 0) - (durationSeconds(a) || 0)); + case 'shortest': + return arr.sort((a, b) => (durationSeconds(a) || 0) - (durationSeconds(b) || 0)); + case 'title': + return arr.sort((a, b) => + sessionDisplayTitle(a).localeCompare(sessionDisplayTitle(b), undefined, { sensitivity: 'base' }), + ); + case 'newest': + default: + return arr.sort((a, b) => new Date(b.started_at || 0) - new Date(a.started_at || 0)); + } +} + +// ── Main pipeline ─────────────────────────────────────────────────────────── + +/** + * Apply every filter and sort in one pass. + * + * @param {Array} sessions + * @param {object} opts + * @param {string} opts.query lowercased search text + * @param {string} opts.status STATUS_FILTERS key + * @param {string} opts.duration DURATION_FILTERS key + * @param {string} opts.platform PLATFORM_FILTERS key + * @param {string} opts.dateFrom YYYY-MM-DD or '' + * @param {string} opts.dateTo YYYY-MM-DD or '' + * @param {string[]} opts.content array of CONTENT_FILTERS keys currently active + * @param {string} opts.sort SORT_OPTIONS key + * @returns {Array} + */ +export function filterAndSort(sessions, opts) { + const { + query = '', + status = 'all', + duration = 'any', + platform = 'any', + dateFrom = '', + dateTo = '', + content = [], + sort = 'newest', + } = opts; + + const q = query.trim().toLowerCase(); + const statusFilter = STATUS_FILTERS.find((f) => f.key === status) || STATUS_FILTERS[0]; + + const filtered = sessions.filter((s) => { + if (!statusFilter.test(s)) return false; + if (!matchesQuery(s, q)) return false; + if (!matchesDuration(s, duration)) return false; + if (!matchesPlatform(s, platform)) return false; + if (!matchesDateRange(s, dateFrom, dateTo)) return false; + for (const ck of content) { + const cf = CONTENT_FILTERS.find((c) => c.key === ck); + if (cf && !cf.test(s)) return false; + } + return true; + }); + + return sortSessions(filtered, sort); +} + +/** + * Count sessions per status filter key. + */ +export function statusCounts(sessions) { + const out = {}; + for (const f of STATUS_FILTERS) out[f.key] = sessions.filter(f.test).length; + return out; +} + +/** + * True when any filter is active beyond the defaults. + */ +export function hasActiveFilters(opts) { + return ( + (opts.query || '').trim() !== '' || + (opts.status || 'all') !== 'all' || + (opts.duration || 'any') !== 'any' || + (opts.platform || 'any') !== 'any' || + (opts.dateFrom || '') !== '' || + (opts.dateTo || '') !== '' || + (opts.content || []).length > 0 || + (opts.sort || 'newest') !== 'newest' + ); +} diff --git a/tests/meetingFilters.test.js b/tests/meetingFilters.test.js new file mode 100644 index 0000000..d5250b1 --- /dev/null +++ b/tests/meetingFilters.test.js @@ -0,0 +1,278 @@ +import { describe, it, expect } from 'vitest'; +import { + filterAndSort, + statusCounts, + hasActiveFilters, + STATUS_FILTERS, + DURATION_FILTERS, + PLATFORM_FILTERS, + CONTENT_FILTERS, + SORT_OPTIONS, +} from '../renderer/lib/meetingFilters.js'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function session(overrides = {}) { + return { + id: 'sess-1', + title: 'Standup', + started_at: '2026-10-01T09:00:00Z', + ended_at: '2026-10-01T09:30:00Z', + duration_seconds: 1800, // 30 min + status: 'complete', + meeting_url: null, + notion_page_url: null, + notes: null, + ...overrides, + }; +} + +const SESSIONS = [ + session({ id: 's1', title: 'Morning Standup', started_at: '2026-10-05T09:00:00Z', duration_seconds: 600, status: 'complete' }), + session({ id: 's2', title: 'Sprint Review', started_at: '2026-10-04T14:00:00Z', duration_seconds: 3600, status: 'complete', meeting_url: 'https://meet.google.com/abc' }), + session({ id: 's3', title: 'One-on-One', started_at: '2026-10-03T11:00:00Z', duration_seconds: 5400, status: 'complete', notion_page_url: 'https://notion.so/123' }), + session({ id: 's4', title: 'Bug Triage', started_at: '2026-10-02T10:00:00Z', duration_seconds: 120, status: 'error' }), + session({ id: 's5', title: 'Retro', started_at: '2026-10-01T16:00:00Z', duration_seconds: 2700, status: 'transcribing', meeting_url: 'https://zoom.us/j/123' }), + session({ + id: 's6', title: 'Planning', started_at: '2026-09-30T10:00:00Z', duration_seconds: 3000, + status: 'complete', notes: { summary: 'Planned next sprint', action_items: [{ text: 'Do X' }] }, + }), +]; + +// ── Export sanity ───────────────────────────────────────────────────────────── + +describe('exported constants', () => { + it('STATUS_FILTERS has at least "all"', () => { + expect(STATUS_FILTERS.find((f) => f.key === 'all')).toBeDefined(); + }); + it('DURATION_FILTERS starts with "any"', () => { + expect(DURATION_FILTERS[0].key).toBe('any'); + }); + it('PLATFORM_FILTERS starts with "any"', () => { + expect(PLATFORM_FILTERS[0].key).toBe('any'); + }); + it('CONTENT_FILTERS includes hasActions', () => { + expect(CONTENT_FILTERS.find((f) => f.key === 'hasActions')).toBeDefined(); + }); + it('SORT_OPTIONS starts with "newest"', () => { + expect(SORT_OPTIONS[0].key).toBe('newest'); + }); +}); + +// ── statusCounts ───────────────────────────────────────────────────────────── + +describe('statusCounts', () => { + it('counts all sessions under "all"', () => { + expect(statusCounts(SESSIONS).all).toBe(SESSIONS.length); + }); + it('counts error sessions under "attention"', () => { + expect(statusCounts(SESSIONS).attention).toBe(1); + }); + it('counts processing sessions', () => { + expect(statusCounts(SESSIONS).processing).toBe(1); + }); + it('counts notion sessions', () => { + expect(statusCounts(SESSIONS).notion).toBe(1); + }); +}); + +// ── hasActiveFilters ───────────────────────────────────────────────────────── + +describe('hasActiveFilters', () => { + it('returns false for default options', () => { + expect(hasActiveFilters({})).toBe(false); + }); + it('returns true when query is set', () => { + expect(hasActiveFilters({ query: 'hello' })).toBe(true); + }); + it('returns true when sort is not newest', () => { + expect(hasActiveFilters({ sort: 'oldest' })).toBe(true); + }); + it('returns true when duration is set', () => { + expect(hasActiveFilters({ duration: 'short' })).toBe(true); + }); + it('returns true when dateFrom is set', () => { + expect(hasActiveFilters({ dateFrom: '2026-10-01' })).toBe(true); + }); + it('returns true when content filters are active', () => { + expect(hasActiveFilters({ content: ['hasActions'] })).toBe(true); + }); +}); + +// ── filterAndSort — text search ────────────────────────────────────────────── + +describe('filterAndSort — text search', () => { + it('returns all sessions with empty query', () => { + expect(filterAndSort(SESSIONS, {}).length).toBe(SESSIONS.length); + }); + + it('filters by title', () => { + const result = filterAndSort(SESSIONS, { query: 'standup' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('s1'); + }); + + it('is case-insensitive', () => { + const result = filterAndSort(SESSIONS, { query: 'SPRINT REVIEW' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('s2'); + }); + + it('searches notes summary', () => { + const result = filterAndSort(SESSIONS, { query: 'planned next' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('s6'); + }); +}); + +// ── filterAndSort — status ─────────────────────────────────────────────────── + +describe('filterAndSort — status filter', () => { + it('filters to error sessions with "attention"', () => { + const result = filterAndSort(SESSIONS, { status: 'attention' }); + expect(result.every((s) => s.status === 'error')).toBe(true); + }); + + it('filters to notion sessions', () => { + const result = filterAndSort(SESSIONS, { status: 'notion' }); + expect(result.every((s) => !!s.notion_page_url)).toBe(true); + }); +}); + +// ── filterAndSort — duration ───────────────────────────────────────────────── + +describe('filterAndSort — duration filter', () => { + it('"short" returns sessions under 15 minutes', () => { + const result = filterAndSort(SESSIONS, { duration: 'short' }); + expect(result.every((s) => s.duration_seconds < 15 * 60)).toBe(true); + expect(result.length).toBe(2); // s1=600s (10m), s4=120s (2m) + }); + + it('"medium" returns sessions between 15–60 minutes', () => { + const result = filterAndSort(SESSIONS, { duration: 'medium' }); + expect(result.every((s) => s.duration_seconds >= 15 * 60 && s.duration_seconds < 60 * 60)).toBe(true); + }); + + it('"long" returns sessions over 60 minutes', () => { + const result = filterAndSort(SESSIONS, { duration: 'long' }); + expect(result.every((s) => s.duration_seconds >= 60 * 60)).toBe(true); + }); +}); + +// ── filterAndSort — platform ───────────────────────────────────────────────── + +describe('filterAndSort — platform filter', () => { + it('"meet" returns only Google Meet sessions', () => { + const result = filterAndSort(SESSIONS, { platform: 'meet' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('s2'); + }); + + it('"zoom" returns only Zoom sessions', () => { + const result = filterAndSort(SESSIONS, { platform: 'zoom' }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('s5'); + }); +}); + +// ── filterAndSort — date range ─────────────────────────────────────────────── + +describe('filterAndSort — date range', () => { + it('filters sessions after dateFrom', () => { + const result = filterAndSort(SESSIONS, { dateFrom: '2026-10-04' }); + expect(result.every((s) => new Date(s.started_at) >= new Date('2026-10-04'))).toBe(true); + }); + + it('filters sessions before dateTo', () => { + const result = filterAndSort(SESSIONS, { dateTo: '2026-10-02' }); + expect(result.every((s) => new Date(s.started_at) <= new Date('2026-10-03'))).toBe(true); + }); + + it('filters sessions within a date range', () => { + const result = filterAndSort(SESSIONS, { dateFrom: '2026-10-02', dateTo: '2026-10-04' }); + expect(result.length).toBe(3); // s2, s3, s4 + }); +}); + +// ── filterAndSort — content ────────────────────────────────────────────────── + +describe('filterAndSort — content filter', () => { + it('"hasActions" returns only sessions with action items', () => { + const result = filterAndSort(SESSIONS, { content: ['hasActions'] }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('s6'); + }); + + it('"hasNotes" returns sessions with notes summary', () => { + const result = filterAndSort(SESSIONS, { content: ['hasNotes'] }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('s6'); + }); +}); + +// ── filterAndSort — sorting ────────────────────────────────────────────────── + +describe('filterAndSort — sorting', () => { + it('"newest" sorts by started_at descending', () => { + const result = filterAndSort(SESSIONS, { sort: 'newest' }); + for (let i = 1; i < result.length; i++) { + expect(new Date(result[i - 1].started_at) >= new Date(result[i].started_at)).toBe(true); + } + }); + + it('"oldest" sorts by started_at ascending', () => { + const result = filterAndSort(SESSIONS, { sort: 'oldest' }); + for (let i = 1; i < result.length; i++) { + expect(new Date(result[i - 1].started_at) <= new Date(result[i].started_at)).toBe(true); + } + }); + + it('"longest" puts the longest session first', () => { + const result = filterAndSort(SESSIONS, { sort: 'longest' }); + expect(result[0].id).toBe('s3'); // 5400s + }); + + it('"shortest" puts the shortest session first', () => { + const result = filterAndSort(SESSIONS, { sort: 'shortest' }); + expect(result[0].id).toBe('s4'); // 120s + }); + + it('"title" sorts alphabetically', () => { + const result = filterAndSort(SESSIONS, { sort: 'title' }); + expect(result[0].title).toBe('Bug Triage'); + expect(result[result.length - 1].title).toBe('Sprint Review'); + }); +}); + +// ── filterAndSort — combined ───────────────────────────────────────────────── + +describe('filterAndSort — combined filters', () => { + it('combines status + duration + search', () => { + const result = filterAndSort(SESSIONS, { + status: 'all', + duration: 'medium', + query: 'retro', + }); + // "Retro" is 2700s (45m) → medium, so it should match. + expect(result.length).toBe(1); + expect(result[0].id).toBe('s5'); + }); + + it('combines date range + platform', () => { + const result = filterAndSort(SESSIONS, { + dateFrom: '2026-10-01', + dateTo: '2026-10-05', + platform: 'meet', + }); + expect(result.length).toBe(1); + expect(result[0].id).toBe('s2'); + }); + + it('returns empty when no sessions match all filters', () => { + const result = filterAndSort(SESSIONS, { + status: 'attention', + platform: 'meet', + }); + expect(result.length).toBe(0); + }); +}); From d88eb47b20c0f01b45ef839bb7a9c877c05303a8 Mon Sep 17 00:00:00 2001 From: Aslah Date: Thu, 8 Oct 2026 13:00:26 +0530 Subject: [PATCH 2/3] feat: release version 4.3.0 with advanced meeting filters, sorting, and new utility module --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cbfeea..a7a2359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ updated: 2026-10-03 Chronological log of notable changes to the project. Newest first. This is a human-curated log — not a mirror of `git log`. +## [4.3.0] - 2026-10-08 + +### Added + +- Date range filtering on the Meetings page: pick a "from" and "to" date to narrow results to a calendar window. +- Duration filter (< 15 min / 15–60 min / > 1 hour) on the Meetings page. +- Platform filter (Google Meet / Zoom / Teams) on the Meetings page. +- Content filters ("Has action items", "Has notes") on the Meetings page. +- Sort selector: newest first (default), oldest first, longest first, shortest first, or title A–Z. +- Collapsible "Filters" panel with active-filter summary chips when closed; individual filters can be dismissed from the chip. +- "X meetings found" count and "Clear all filters" shortcut when any filter is active. +- Pure `meetingFilters.js` utility module with 39 unit tests covering all filter/sort logic. + ## [4.2.0] - 2026-10-03 ### Added diff --git a/package.json b/package.json index fc928c7..1cfa86f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meetmind", - "version": "4.2.0", + "version": "4.3.0", "description": "Open-source AI meeting notes app for Windows: record Google Meet and Zoom calls, transcribe with AssemblyAI, Google STT, Sarvam AI or Groq, summarize with Gemini or Groq, and sync to Notion.", "keywords": [ "ai-meeting-notes", From fb69edff71a6e6739600c03551f40eb5e45605e9 Mon Sep 17 00:00:00 2001 From: Aslah Date: Thu, 8 Oct 2026 13:10:01 +0530 Subject: [PATCH 3/3] feat: add core extension manifest and meeting management renderer components --- AGENTS.md | 2 +- CHANGELOG.md | 15 +- docs/ARCHITECTURE.md | 9 +- extension/manifest.json | 2 +- renderer/components/Meetings.jsx | 494 +++++++++++++----------- renderer/components/note/NoteHeader.jsx | 130 ++----- renderer/components/ui/Menu.jsx | 138 +++++++ renderer/components/ui/index.jsx | 1 + renderer/lib/meetingFilters.js | 63 ++- renderer/styles/globals.css | 17 + tests/meetingFilters.test.js | 43 ++- 11 files changed, 565 insertions(+), 349 deletions(-) create mode 100644 renderer/components/ui/Menu.jsx diff --git a/AGENTS.md b/AGENTS.md index a2dac1f..a1c6766 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Three artifacts build from this one repo: the Electron desktop app, its React re ## Commands -Run `pnpm run lint` and `pnpm run test` before finishing a change. The tests cover pure logic only (note Markdown export/parse, formatting, PDF file naming, waveform peaks), so also verify UI and main-process changes by running the app (`pnpm run dev`) and exercising the affected flow directly. +Run `pnpm run lint` and `pnpm run test` before finishing a change. The tests cover pure logic only (note Markdown export/parse, formatting, PDF file naming, waveform peaks, meeting filters/sort), so also verify UI and main-process changes by running the app (`pnpm run dev`) and exercising the affected flow directly. The package manager is pnpm (pinned via `packageManager` in `package.json`); never use npm/npx or commit a `package-lock.json`. `pnpm-workspace.yaml` sets `nodeLinker: hoisted` so electron-builder sees a flat `node_modules`, and lists the dependencies allowed to run install scripts under `allowBuilds` (add new ones with `pnpm approve-builds `). `verifyDepsBeforeRun: warn` stops `pnpm run` from silently reinstalling after `package.json` changes, because that reinstall replaces `node_modules/electron` and breaks it if the app is running; run `pnpm install` explicitly with the app closed. If Electron reports it "failed to install correctly", run `pnpm rebuild electron`. `minimumReleaseAge: 1440` makes pnpm refuse package versions published less than a day ago, a supply-chain guard; Dependabot has its own 7-day `cooldown` in `.github/dependabot.yml`. If `pnpm add`/`pnpm update` says a version is too new, wait, or for an urgent security fix add the package to `minimumReleaseAgeExclude` temporarily. Never remove the setting. diff --git a/CHANGELOG.md b/CHANGELOG.md index a7a2359..f10ba7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,13 @@ Chronological log of notable changes to the project. Newest first. This is a hum ### Added -- Date range filtering on the Meetings page: pick a "from" and "to" date to narrow results to a calendar window. -- Duration filter (< 15 min / 15–60 min / > 1 hour) on the Meetings page. -- Platform filter (Google Meet / Zoom / Teams) on the Meetings page. -- Content filters ("Has action items", "Has notes") on the Meetings page. -- Sort selector: newest first (default), oldest first, longest first, shortest first, or title A–Z. -- Collapsible "Filters" panel with active-filter summary chips when closed; individual filters can be dismissed from the chip. -- "X meetings found" count and "Clear all filters" shortcut when any filter is active. -- Pure `meetingFilters.js` utility module with 39 unit tests covering all filter/sort logic. +- Meetings page "Filters" panel: date (Any time, Today, Last 7 days, Last 30 days or a custom from/to range), length (under 15 min, 15–60 min, over 1 hour), platform (Google Meet, Zoom, Teams) and content ("Has action items", "Has notes"). +- Sort menu on the Meetings page: newest first (default), oldest first, longest first, shortest first, or title A–Z; sorting never counts as a filter. +- The Filters button shows how many filters are applied; with the panel closed, applied filters appear as dismissible chips beside an "N of M meetings" count and a "Clear all" shortcut. + +### Changed + +- The Notion menu on a meeting page now shares the Meetings sort menu's keyboard behaviour (arrow keys, Home/End, Escape). ## [4.2.0] - 2026-10-03 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21bedeb..68ef1fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -172,7 +172,9 @@ it. React 18 + Vite 6, no router: `renderer/app.jsx` is a single view-state switch between: - `Dashboard.jsx`: stats and a recent-sessions list. -- `Meetings.jsx`: full session list. +- `Meetings.jsx`: full session list with search, status chips, a sort menu and a Filters panel + (date preset or range, length, platform, content); the filter/sort logic is pure, in + `lib/meetingFilters.js`. - `NoteViewer.jsx` / `TranscriptViewer.jsx`: a completed session's notes or raw transcript. - `Settings.jsx`, composed of `settings/GeneralSection.jsx`, `TranscriptionSection.jsx`, `NotesSection.jsx`, `IntegrationsSection.jsx`, `SystemSection.jsx`: API keys, service @@ -183,10 +185,11 @@ Supporting pieces: `RecordingBar.jsx` (persistent recording control), `Processin (pipeline progress), `SessionCard.jsx`, `UpcomingMeetings.jsx` (Google Calendar), `PasteTranscriptModal.jsx` (manual transcript import), `note/` (Markdown rendering, `copyMarkdown.js` for JSON<->Markdown conversion, `pdfDocument.jsx` for the PDF export -template, `AudioPlayer.jsx` streaming from `meetmind-audio://`), `ui/` (Dialog, ConfirmDialog). +template, `AudioPlayer.jsx` streaming from `meetmind-audio://`), `ui/` (Dialog, ConfirmDialog, +`Menu.jsx` for keyboard-navigable dropdown menus). State/plumbing: `lib/app-context.js`, `lib/hooks.js`, `lib/status.js`, `lib/format.js`, -`lib/platform.js`, `lib/scrollMemory.js`, `lib/audioMeter.js`, `lib/greeting.js`. +`lib/platform.js`, `lib/meetingFilters.js`, `lib/scrollMemory.js`, `lib/audioMeter.js`, `lib/greeting.js`. Styling: Tailwind CSS (`darkMode: 'class'`, with `` toggled by preload/React based on the `theme` setting) driven by CSS custom properties in `renderer/styles/globals.css` diff --git a/extension/manifest.json b/extension/manifest.json index f49c9de..2938670 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "MeetMind", - "version": "4.2.0", + "version": "4.3.0", "description": "AI meeting notes — auto-detects Google Meet & Zoom. Requires the MeetMind desktop app.", "permissions": [ "tabs", diff --git a/renderer/components/Meetings.jsx b/renderer/components/Meetings.jsx index 282a9b4..1333390 100644 --- a/renderer/components/Meetings.jsx +++ b/renderer/components/Meetings.jsx @@ -1,16 +1,20 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react'; import { - RefreshCw, Upload, ClipboardList, Search, X, Mic, - ArrowUpDown, Calendar, ChevronDown, ChevronUp, SlidersHorizontal, + RefreshCw, Upload, ClipboardList, Search, X, Mic, Check, + ArrowUpDown, ChevronDown, SlidersHorizontal, } from 'lucide-react'; import { useApp } from '../lib/app-context.js'; import PasteTranscriptModal from './PasteTranscriptModal.jsx'; import { SessionList, SessionListSkeleton } from './SessionCard.jsx'; -import { PageHeader, IconButton, EmptyState, Skeleton, StatusDot } from './ui/index.jsx'; +import { + PageHeader, IconButton, EmptyState, Skeleton, StatusDot, SegmentedControl, + Menu, MenuRadioItem, MenuLabel, +} from './ui/index.jsx'; import { useDelayedFlag, useSessionActions, SKELETON_DELAY_MS } from '../lib/hooks.js'; import { useScrollMemory, useRememberedState } from '../lib/scrollMemory.js'; import { STATUS_FILTERS, + DATE_PRESETS, DURATION_FILTERS, PLATFORM_FILTERS, CONTENT_FILTERS, @@ -18,15 +22,29 @@ import { filterAndSort, statusCounts, hasActiveFilters, + activeFilterCount, + resolveDateRange, } from '../lib/meetingFilters.js'; // ── Helpers ────────────────────────────────────────────────────────────────── -/** Format a native input date value for display. */ +const asOptions = (list) => list.map((f) => ({ value: f.key, label: f.label })); +const DATE_OPTIONS = asOptions(DATE_PRESETS); +const DURATION_OPTIONS = asOptions(DURATION_FILTERS); +const PLATFORM_OPTIONS = asOptions(PLATFORM_FILTERS); + +/** Format a YYYY-MM-DD input value for display, dropping the year when it's this year. */ function friendlyDate(isoDate) { if (!isoDate) return ''; const d = new Date(isoDate + 'T00:00:00'); // local midnight - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + const sameYear = d.getFullYear() === new Date().getFullYear(); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }) }); +} + +function customRangeLabel(from, to) { + if (from && to) return from === to ? friendlyDate(from) : `${friendlyDate(from)} – ${friendlyDate(to)}`; + if (from) return `Since ${friendlyDate(from)}`; + return `Until ${friendlyDate(to)}`; } // ── Sub-components ─────────────────────────────────────────────────────────── @@ -46,82 +64,101 @@ function MeetingsSkeleton() { ); } -/** Inline date range picker — two native date inputs with clear. */ -function DateRangePicker({ dateFrom, dateTo, onChange }) { - const hasDates = dateFrom || dateTo; +/** Sort dropdown: a ghost pill naming the current order, opening a checked radio menu. */ +function SortMenu({ value, onChange }) { + const current = SORT_OPTIONS.find((o) => o.key === value) || SORT_OPTIONS[0]; + return ( + ( + + )} + > + Sort by + {SORT_OPTIONS.map((o) => ( + onChange(o.key)}> + {o.label} + + ))} + + ); +} + +/** + * One labelled row of the Filters panel. On wide windows the label sits beside the control, + * padded to line up with the centre of a small pill control. + */ +function FilterField({ label, id, aside, children }) { + return ( +
+ {label} +
{children}
+ {aside &&
{aside}
} +
+ ); +} +/** From/to date inputs, shown under the "Custom" date preset. */ +function CustomDateRange({ dateFrom, dateTo, onChange }) { return ( -
-