diff --git a/apps/desktop/src/main/tasks/project-item-links.test.ts b/apps/desktop/src/main/tasks/project-item-links.test.ts index 6c0da7e05..ad9cb0489 100644 --- a/apps/desktop/src/main/tasks/project-item-links.test.ts +++ b/apps/desktop/src/main/tasks/project-item-links.test.ts @@ -81,6 +81,37 @@ describe('project item link reroute', () => { expect(setEntityProperties).toHaveBeenCalledWith('n1', { project: ['Beta'] }) }) + // The chips on the file page are the only unassign surface a binary file + // has. It owns no frontmatter, so the row in `project_links` is the whole of + // its membership and this branch is the entire write. + it('deletes the link row for a file on unlink, writing no frontmatter', async () => { + isMarkdownNote.mockReturnValue(false) + domainUnlink.mockResolvedValue({ success: true }) + + const result = await unlinkProjectItem({} as never, domain as never, { + projectId: 'p1', + itemType: 'file', + itemId: 'f1' + }) + + expect(result).toEqual({ success: true }) + expect(domainUnlink).toHaveBeenCalledWith({ projectId: 'p1', itemType: 'file', itemId: 'f1' }) + expect(setEntityProperties).not.toHaveBeenCalled() + }) + + it('reports a failed file unlink instead of claiming success', async () => { + isMarkdownNote.mockReturnValue(false) + domainUnlink.mockResolvedValue({ success: false }) + + expect( + await unlinkProjectItem({} as never, domain as never, { + projectId: 'p1', + itemType: 'file', + itemId: 'f1' + }) + ).toEqual({ success: false, error: 'Failed to unlink item' }) + }) + it('errors when the project does not exist', async () => { isMarkdownNote.mockReturnValue(true) getProjectById.mockReturnValue(undefined) diff --git a/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx b/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx index 79c683835..09a7444dc 100644 --- a/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx +++ b/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx @@ -31,10 +31,28 @@ const SOURCE_TYPE_BY_VISUAL_TYPE = { note: 'note' } as const +/** + * The instant these tests pretend it is, on today's real local date. + * + * `use-today` snapshots the local date into module scope at import and re-reads the wall clock + * for its first subscriber. A clock faked onto any other date therefore arrives as a midnight + * rollover, which moves `todayCalendarRange`, moves the query key with it, and makes the widget + * fetch a second day on mount. Only the time of day is pinned, and from local fields rather than + * a UTC instant, which far enough from UTC would name a different day. + */ +const NOW = new Date() +NOW.setHours(9, 30, 0, 0) + +function todayAtHour(hour: number): string { + const at = new Date(NOW) + at.setHours(hour, 0, 0, 0) + return at.toISOString() +} + function projectionItem( id: string, title: string, - hourUtc: number, + hour: number, visualType: keyof typeof SOURCE_TYPE_BY_VISUAL_TYPE ): CalendarProjectionItem { return { @@ -43,8 +61,8 @@ function projectionItem( sourceId: id, title, descriptionPreview: null, - startAt: `2026-08-31T${String(hourUtc).padStart(2, '0')}:00:00.000Z`, - endAt: `2026-08-31T${String(hourUtc + 1).padStart(2, '0')}:00:00.000Z`, + startAt: todayAtHour(hour), + endAt: todayAtHour(hour + 1), isAllDay: false, timezone: 'UTC', visualType, @@ -101,7 +119,7 @@ function renderApp(): { showBoard: (visible: boolean) => void } { describe('home calendar widget stays current', () => { beforeEach(() => { - vi.setSystemTime(new Date('2026-08-31T09:30:00.000Z')) + vi.setSystemTime(NOW) listeners.clear() server.items = [projectionItem('e1', 'Standup', 10, 'event')] mockGetRange.mockReset() diff --git a/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.test.tsx b/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.test.tsx index 5c3f3462d..5acaf372c 100644 --- a/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.test.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.test.tsx @@ -2,22 +2,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { ItemProjectChips } from './item-project-chips' -const { mockListForItem, mockOnProjectUpdated } = vi.hoisted(() => ({ - mockListForItem: vi.fn(), - mockOnProjectUpdated: vi.fn(() => () => {}) -})) +const { mockListForItem, mockOnProjectUpdated, mockUnlinkProjectItem, mockToastError } = vi.hoisted( + () => ({ + mockListForItem: vi.fn(), + // Declares the subscriber parameter the component actually passes, so + // the mockImplementation below that captures it still typechecks. + mockOnProjectUpdated: vi.fn((_callback: () => void) => () => {}), + mockUnlinkProjectItem: vi.fn(), + mockToastError: vi.fn() + }) +) vi.mock('@/services/tasks-service', () => ({ tasksService: { - listForItem: mockListForItem + listForItem: mockListForItem, + unlinkProjectItem: mockUnlinkProjectItem }, onProjectUpdated: mockOnProjectUpdated })) +vi.mock('sonner', () => ({ toast: { error: mockToastError } })) + describe('ItemProjectChips', () => { beforeEach(() => { vi.clearAllMocks() mockOnProjectUpdated.mockReturnValue(() => {}) + mockUnlinkProjectItem.mockResolvedValue({ success: true }) }) it('renders a chip per linked project', async () => { @@ -79,4 +89,69 @@ describe('ItemProjectChips', () => { expect(await screen.findByText('Launch')).toBeInTheDocument() expect(mockListForItem).toHaveBeenCalledTimes(2) }) + + // A binary file has no frontmatter, so these chips are the whole of its + // project membership UI. Without a remove control on them the only exits + // from a project are destructive (issue #1941). + it('unlinks the item when a chip remove control is used', async () => { + mockListForItem + .mockResolvedValueOnce([{ id: 'p1', name: 'Launch', color: '#f00', icon: null }]) + .mockResolvedValue([]) + + render() + + fireEvent.click(await screen.findByRole('button', { name: /remove from launch/i })) + + await waitFor(() => + expect(mockUnlinkProjectItem).toHaveBeenCalledWith({ + projectId: 'p1', + itemType: 'file', + itemId: 'f1' + }) + ) + await waitFor(() => expect(screen.queryByText('Launch')).not.toBeInTheDocument()) + }) + + it('gives each chip its own remove control, naming the project it drops', async () => { + mockListForItem.mockResolvedValue([ + { id: 'p1', name: 'Launch', color: '#f00', icon: null }, + { id: 'p2', name: 'Finance', color: '#0f0', icon: null } + ]) + + render() + + fireEvent.click(await screen.findByRole('button', { name: /remove from finance/i })) + + await waitFor(() => + expect(mockUnlinkProjectItem).toHaveBeenCalledWith({ + projectId: 'p2', + itemType: 'file', + itemId: 'f1' + }) + ) + }) + + it('renders the name as plain text where there is nowhere to navigate', async () => { + mockListForItem.mockResolvedValue([{ id: 'p1', name: 'Launch', color: '#f00', icon: null }]) + + render() + + expect(await screen.findByText('Launch')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /open project launch/i })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /remove from launch/i })).toBeInTheDocument() + }) + + // The main side answers a failed unlink with an envelope rather than a + // rejection, so a bare await would read it as success and blank the chip. + it('keeps the chip and reports a rejected unlink', async () => { + mockListForItem.mockResolvedValue([{ id: 'p1', name: 'Launch', color: '#f00', icon: null }]) + mockUnlinkProjectItem.mockResolvedValue({ success: false, error: 'link is gone' }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: /remove from launch/i })) + + await waitFor(() => expect(mockToastError).toHaveBeenCalledWith('link is gone')) + expect(screen.getByText('Launch')).toBeInTheDocument() + }) }) diff --git a/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.tsx b/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.tsx index 80f00d98e..ea4704ff0 100644 --- a/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import { X } from '@/lib/icons' import { cn } from '@/lib/utils' import { tasksService, @@ -12,6 +14,19 @@ import { useT } from '@memry/i18n/renderer' const log = createLogger('ItemProjectChips') +function ProjectChipLabel({ project }: { project: ProjectRef }): React.JSX.Element { + return ( + <> +