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 1cbfeea..f10ba7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ 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 + +- 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 ### Added 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/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", diff --git a/renderer/components/Meetings.jsx b/renderer/components/Meetings.jsx index 71ac1b1..1333390 100644 --- a/renderer/components/Meetings.jsx +++ b/renderer/components/Meetings.jsx @@ -1,32 +1,54 @@ 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, 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 { sessionDisplayTitle } from '../lib/format.js'; -import { isProcessing } from '../lib/status.js'; +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, + SORT_OPTIONS, + filterAndSort, + statusCounts, + hasActiveFilters, + activeFilterCount, + resolveDateRange, +} 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 ────────────────────────────────────────────────────────────────── + +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 + 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 ─────────────────────────────────────────────────────────── + function MeetingsSkeleton() { return (
@@ -42,32 +64,167 @@ function MeetingsSkeleton() { ); } +/** 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 ( +
+ + +
+ ); +} + +/** Multi-select toggle pills for the content filters. */ +function ContentToggles({ active, onChange, labelledBy }) { + return ( +
+ {CONTENT_FILTERS.map((cf) => { + const on = active.includes(cf.key); + return ( + + ); + })} +
+ ); +} + +/** Applied panel filters as dismissible Dark Filter Chips. */ +function AppliedFilters({ chips }) { + if (chips.length === 0) return null; + return ( + + ); +} + +// ── 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 [datePreset, setDatePreset] = useRememberedState('meetings:datePreset', 'any'); + const [customFrom, setCustomFrom] = useRememberedState('meetings:dateFrom', ''); + const [customTo, setCustomTo] = useRememberedState('meetings:dateTo', ''); + const [duration, setDuration] = useRememberedState('meetings:duration', 'any'); + const [platform, setPlatform] = useRememberedState('meetings:platform', 'any'); + 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(() => { + const { dateFrom, dateTo } = resolveDateRange(datePreset, customFrom, customTo); + return { query, status, duration, platform, dateFrom, dateTo, content, sort }; + }, [query, status, datePreset, customFrom, customTo, duration, platform, content, sort]); + + const visible = useMemo(() => filterAndSort(sessions, filterOpts), [sessions, filterOpts]); + const isFiltered = hasActiveFilters(filterOpts); + const panelCount = activeFilterCount(filterOpts); const openPaste = useCallback(() => setShowPasteModal(true), []); const closePaste = useCallback(() => setShowPasteModal(false), []); @@ -81,11 +238,55 @@ export default function Meetings({ onOpenSession }) { } }; - const clearFilters = () => { + const clearDates = () => { + setDatePreset('any'); + setCustomFrom(''); + setCustomTo(''); + }; + + // Panel filters only; search, status and sort keep their own controls. + const resetPanelFilters = () => { + clearDates(); + setDuration('any'); + setPlatform('any'); + setContent([]); + }; + + // Everything that narrows the list. Sort only orders it, so it stays. + const clearAllFilters = () => { setQuery(''); - setFilter('all'); + setStatus('all'); + resetPanelFilters(); }; + const appliedChips = []; + if (filterOpts.dateFrom || filterOpts.dateTo) { + appliedChips.push({ + key: 'dates', + label: datePreset === 'custom' + ? customRangeLabel(filterOpts.dateFrom, filterOpts.dateTo) + : DATE_PRESETS.find((p) => p.key === datePreset)?.label, + clear: clearDates, + }); + } + if (duration !== 'any') { + const d = DURATION_FILTERS.find((f) => f.key === duration); + appliedChips.push({ key: 'duration', label: d?.label || duration, clear: () => setDuration('any') }); + } + if (platform !== 'any') { + const p = PLATFORM_FILTERS.find((f) => f.key === platform); + appliedChips.push({ key: 'platform', label: p?.label || platform, clear: () => setPlatform('any') }); + } + for (const cf of CONTENT_FILTERS) { + if (content.includes(cf.key)) { + appliedChips.push({ + key: cf.key, + label: cf.label, + clear: () => setContent((prev) => prev.filter((k) => k !== cf.key)), + }); + } + } + if (sessionsLoading) { return showSkeleton ? :
; } @@ -148,10 +349,15 @@ export default function Meetings({ onOpenSession }) { ) ) : ( <> - {/* Search + filters */} -
+ {/* ── Toolbar: search · sort · filters, all stretched to the search field's height. */} +
- +
-
- {FILTERS.map((f) => { - const active = filter === f.key; + + + + +
+ + {/* ── Filters panel, directly under the button that opens it. */} + {filtersOpen && ( +
+ + + {datePreset === 'custom' && ( + { + setCustomFrom(dateFrom); + setCustomTo(dateTo); + }} + /> + )} + + + + + + + + + + + + Reset filters + + } + > + + +
+ )} + + {/* ── Status quick filters, then a results line when anything narrows the list. */} +
+
+ {STATUS_FILTERS.map((f) => { + const active = status === f.key; return ( ); })}
+ + {isFiltered && ( +
+

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

+ {/* While the panel is open its own controls show what's applied. */} + {!filtersOpen && } + +
+ )}
{visible.length === 0 ? ( @@ -188,7 +500,7 @@ export default function Meetings({ onOpenSession }) { title="No meetings match" message="Try a different search, or clear the filters to see everything." action={ - } diff --git a/renderer/components/note/NoteHeader.jsx b/renderer/components/note/NoteHeader.jsx index 35d701e..95792a4 100644 --- a/renderer/components/note/NoteHeader.jsx +++ b/renderer/components/note/NoteHeader.jsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useEffect, useRef, useState } from 'react'; +import React, { forwardRef } from 'react'; import { ChevronLeft, ChevronDown, @@ -11,7 +11,7 @@ import { Loader2, AlertTriangle, } from 'lucide-react'; -import { IconButton, SegmentedControl } from '../ui/index.jsx'; +import { IconButton, SegmentedControl, Menu, MenuItem } from '../ui/index.jsx'; import NotionIcon from '../NotionIcon.jsx'; import { metaText } from './meta.js'; import { meetingHostname, meetingPlatform } from '../../lib/platform.js'; @@ -84,104 +84,46 @@ function metaChips(session, notes, providers) { return chips; } -const MENU_ITEM = 'flex w-full items-center gap-8 rounded-input px-16 py-8 text-left text-caption font-medium text-ink transition-colors duration-150 hover:bg-ink/[0.06] focus-visible:bg-ink/[0.06] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent'; - // A single "Notion" button for a meeting that already has a page; it opens a small menu with // the two things you can do with that page: open it, or replace it with the current notes. function NotionMenu({ notionUrl, uploading, updateDisabled, updateTitle, onUpdate }) { - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - const triggerRef = useRef(null); - const menuRef = useRef(null); - - useEffect(() => { - if (!open) return undefined; - const items = () => [...(menuRef.current?.querySelectorAll('[role="menuitem"]:not(:disabled)') || [])]; - items()[0]?.focus(); - const onPointerDown = (e) => { - if (!rootRef.current?.contains(e.target)) setOpen(false); - }; - const onKeyDown = (e) => { - if (e.key === 'Escape') { - e.stopPropagation(); - setOpen(false); - triggerRef.current?.focus(); - } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault(); - const list = items(); - if (!list.length) return; - const at = list.indexOf(document.activeElement); - const step = e.key === 'ArrowDown' ? 1 : -1; - list[(at + step + list.length) % list.length].focus(); - } else if (e.key === 'Tab') { - setOpen(false); - } - }; - document.addEventListener('mousedown', onPointerDown); - document.addEventListener('keydown', onKeyDown, true); - return () => { - document.removeEventListener('mousedown', onPointerDown); - document.removeEventListener('keydown', onKeyDown, true); - }; - }, [open]); - - const choose = (action) => { - setOpen(false); - action(); - }; - return ( -
- - - {open && ( -
( + - -
+ {uploading ? : } + {uploading ? 'Updating…' : 'Notion'} +
+ > + + + ); } diff --git a/renderer/components/ui/Menu.jsx b/renderer/components/ui/Menu.jsx new file mode 100644 index 0000000..6da35f6 --- /dev/null +++ b/renderer/components/ui/Menu.jsx @@ -0,0 +1,138 @@ +import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; +import { Check } from 'lucide-react'; + +const MenuContext = createContext(null); + +const ITEM_SELECTOR = '[role^="menuitem"]:not(:disabled)'; + +export const MENU_ITEM = 'flex w-full items-center gap-8 rounded-input px-16 py-8 text-left text-caption font-medium text-ink transition-colors duration-150 hover:bg-ink/[0.06] focus-visible:bg-ink/[0.06] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent'; + +/** + * Small dropdown menu: a "floating" panel (2px ink border, never a shadow) anchored to its trigger. + * Opening focuses the selected (or first) item; arrows/Home/End move, Escape closes and returns + * focus to the trigger, Tab or a click outside closes. + * + * `trigger(props)` renders the button and must spread `props` onto it. + */ +export default function Menu({ label, trigger, align = 'right', minWidth = 200, className = '', children }) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => { + if (!open) return undefined; + const items = () => [...(menuRef.current?.querySelectorAll(ITEM_SELECTOR) || [])]; + const list = items(); + (list.find((el) => el.getAttribute('aria-checked') === 'true') || list[0])?.focus(); + + const onPointerDown = (e) => { + if (!rootRef.current?.contains(e.target)) setOpen(false); + }; + const onKeyDown = (e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + setOpen(false); + triggerRef.current?.focus(); + } else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) { + e.preventDefault(); + const all = items(); + if (!all.length) return; + const at = all.indexOf(document.activeElement); + let next = 0; + if (e.key === 'ArrowDown') next = (at + 1) % all.length; + if (e.key === 'ArrowUp') next = (at - 1 + all.length) % all.length; + if (e.key === 'End') next = all.length - 1; + all[next].focus(); + } else if (e.key === 'Tab') { + setOpen(false); + } + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown, true); + }; + }, [open]); + + const close = (refocus = true) => { + setOpen(false); + if (refocus) triggerRef.current?.focus(); + }; + + return ( + +
+ {trigger({ + ref: triggerRef, + onClick: () => setOpen((o) => !o), + 'aria-haspopup': 'menu', + 'aria-expanded': open, + open, + })} + {open && ( +
+ {children} +
+ )} +
+
+ ); +} + +/** An action in a Menu. Closes the menu, then runs `onSelect`. */ +export function MenuItem({ onSelect, icon, children, refocus = false, ...rest }) { + const close = useContext(MenuContext); + return ( + + ); +} + +/** One choice in a single-select Menu group; the selected one carries a check. */ +export function MenuRadioItem({ checked, onSelect, children }) { + const close = useContext(MenuContext); + return ( + + ); +} + +/** Small uppercase heading over a group of menu items. */ +export function MenuLabel({ children }) { + return ; +} diff --git a/renderer/components/ui/index.jsx b/renderer/components/ui/index.jsx index ad876c2..acda64b 100644 --- a/renderer/components/ui/index.jsx +++ b/renderer/components/ui/index.jsx @@ -4,6 +4,7 @@ import { sessionStatus } from '../../lib/status.js'; export { default as Dialog } from './Dialog.jsx'; export { useConfirmDialog } from './ConfirmDialog.jsx'; +export { default as Menu, MenuItem, MenuRadioItem, MenuLabel } from './Menu.jsx'; // ── IconButton — icon-only buttons must carry an accessible label ───────────── export function IconButton({ label, children, className = '', pressed, ...rest }) { diff --git a/renderer/lib/meetingFilters.js b/renderer/lib/meetingFilters.js new file mode 100644 index 0000000..b6a4651 --- /dev/null +++ b/renderer/lib/meetingFilters.js @@ -0,0 +1,241 @@ +// 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: 'Under 15 min', max: 15 * 60 }, + { key: 'medium', label: '15–60 min', min: 15 * 60, max: 60 * 60 }, + { key: 'long', label: 'Over 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 ────────────────────────────────────────────────────────────── + +function localIsoDate(date) { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +// Relative presets are stored by key, not as dates, so "Last 7 days" stays relative to today +// when the remembered filter is restored later. `days` counts today as the first day. +export const DATE_PRESETS = [ + { key: 'any', label: 'Any time' }, + { key: 'today', label: 'Today', days: 1 }, + { key: '7d', label: 'Last 7 days', days: 7 }, + { key: '30d', label: 'Last 30 days', days: 30 }, + { key: 'custom', label: 'Custom' }, +]; + +/** + * Resolve a date preset (plus the custom range, used only for 'custom') to + * inclusive YYYY-MM-DD bounds. Either bound may be '' (open-ended). + */ +export function resolveDateRange(preset, dateFrom = '', dateTo = '', now = new Date()) { + if (preset === 'custom') return { dateFrom: dateFrom || '', dateTo: dateTo || '' }; + const p = DATE_PRESETS.find((d) => d.key === preset); + if (!p?.days) return { dateFrom: '', dateTo: '' }; + const from = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (p.days - 1)); + return { dateFrom: localIsoDate(from), dateTo: localIsoDate(now) }; +} + +/** + * @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 localDate = localIsoDate(new Date(ts)); + 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; +} + +/** + * Number of filters set in the Filters panel (date, length, platform, content). + * Search, status and sort have their own always-visible controls, so they don't count. + */ +export function activeFilterCount(opts) { + return ( + ((opts.dateFrom || '') !== '' || (opts.dateTo || '') !== '' ? 1 : 0) + + ((opts.duration || 'any') !== 'any' ? 1 : 0) + + ((opts.platform || 'any') !== 'any' ? 1 : 0) + + (opts.content || []).length + ); +} + +/** + * True when anything narrows the list. Sort only reorders it, so it never counts. + */ +export function hasActiveFilters(opts) { + return ( + (opts.query || '').trim() !== '' || + (opts.status || 'all') !== 'all' || + activeFilterCount(opts) > 0 + ); +} diff --git a/renderer/styles/globals.css b/renderer/styles/globals.css index c4d9249..c76c166 100644 --- a/renderer/styles/globals.css +++ b/renderer/styles/globals.css @@ -258,6 +258,23 @@ h1, h2, h3, h4 { outline: 2px solid rgb(var(--color-ink)); outline-offset: 0; } + /* Compact date field (Meetings filters): sized to its content, with a quiet calendar glyph. */ + .input-date { + @apply w-auto px-16 py-4 text-caption tabular-nums; + } + .input-date::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.55; + transition: opacity 150ms; + } + .input-date::-webkit-calendar-picker-indicator:hover { opacity: 1; } + + /* Small ink count on a button, e.g. "Filters 2". */ + .count-badge { + @apply inline-flex items-center justify-center min-w-[20px] h-[20px] px-4 rounded-full + bg-ink text-paper text-caption font-medium leading-none tabular-nums; + } + .label { @apply block text-caption font-medium text-ink mb-8; } diff --git a/tests/meetingFilters.test.js b/tests/meetingFilters.test.js new file mode 100644 index 0000000..c1aeacd --- /dev/null +++ b/tests/meetingFilters.test.js @@ -0,0 +1,317 @@ +import { describe, it, expect } from 'vitest'; +import { + filterAndSort, + statusCounts, + hasActiveFilters, + activeFilterCount, + resolveDateRange, + 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('ignores sort, which only reorders the list', () => { + expect(hasActiveFilters({ sort: 'oldest' })).toBe(false); + }); + 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); + }); +}); + +// ── activeFilterCount ──────────────────────────────────────────────────────── + +describe('activeFilterCount', () => { + it('is 0 for defaults, search, status and sort', () => { + expect(activeFilterCount({})).toBe(0); + expect(activeFilterCount({ query: 'x', status: 'notion', sort: 'title' })).toBe(0); + }); + it('counts a date range once, even with both bounds', () => { + expect(activeFilterCount({ dateFrom: '2026-10-01', dateTo: '2026-10-08' })).toBe(1); + }); + it('counts length, platform and each content filter', () => { + expect(activeFilterCount({ duration: 'long', platform: 'zoom', content: ['hasActions', 'hasNotes'] })).toBe(4); + }); +}); + +// ── resolveDateRange ───────────────────────────────────────────────────────── + +describe('resolveDateRange', () => { + const now = new Date(2026, 9, 8, 15, 30); // Oct 8, 2026, local time + + it('returns open bounds for "any"', () => { + expect(resolveDateRange('any', '2026-01-01', '2026-02-01', now)).toEqual({ dateFrom: '', dateTo: '' }); + }); + it('"today" covers only today', () => { + expect(resolveDateRange('today', '', '', now)).toEqual({ dateFrom: '2026-10-08', dateTo: '2026-10-08' }); + }); + it('"7d" counts today as the first of seven days', () => { + expect(resolveDateRange('7d', '', '', now)).toEqual({ dateFrom: '2026-10-02', dateTo: '2026-10-08' }); + }); + it('"30d" crosses month boundaries', () => { + expect(resolveDateRange('30d', '', '', now)).toEqual({ dateFrom: '2026-09-09', dateTo: '2026-10-08' }); + }); + it('"custom" passes the picked dates through', () => { + expect(resolveDateRange('custom', '2026-10-01', '', now)).toEqual({ dateFrom: '2026-10-01', dateTo: '' }); + }); +}); + +// ── 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); + }); +});