diff --git a/CHANGELOG.md b/CHANGELOG.md index b42373c5..5fd400f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## 2026-09-24 + +### Changed +- Home page now displays only the last 24 items. [Issue 79](https://github.com/thermcampos/tasknote/issues/79) + +``` +# Docker images +- ghcr.io/thermcampos/tasknote/server:api-v2026.09.24.? +- ghcr.io/thermcampos/tasknote/client:app-v2026.09.24.? +``` + +--- + ## 2026-09-23 ### Changed diff --git a/client/src/__test__/views/Home.test.tsx b/client/src/__test__/views/Home.test.tsx index 8f242100..dbe6cbf0 100644 --- a/client/src/__test__/views/Home.test.tsx +++ b/client/src/__test__/views/Home.test.tsx @@ -182,17 +182,55 @@ describe('Home Component', () => { ); + // Mock the backend /rest/home/items endpoint replicating search/tag/type semantics + const filterItems = (url: string) => { + const queryString = url.includes('?') ? url.substring(url.indexOf('?') + 1) : ''; + const params = new URLSearchParams(queryString); + const q = (params.get('q') ?? '').toLowerCase(); + const tag = params.get('tag'); + const type = params.get('type'); + + let tasksResult = type === 'notes' ? [] : [...mockTasks]; + let notesResult = type === 'tasks' ? [] : [...mockNotes]; + + if (q) { + tasksResult = tasksResult.filter(task => + task.description.toLowerCase().includes(q) + || task.tags?.some(tagName => tagName.toLowerCase().includes(q)) + || task.urls.some(url => url.includes(q))); + notesResult = notesResult.filter(note => + note.title.toLowerCase().includes(q) + || note.description.toLowerCase().includes(q) + || note.url?.includes(q) + || note.tags?.some(tagName => tagName.toLowerCase().includes(q))); + } + + if (tag === 'untagged') { + tasksResult = tasksResult.filter(task => !task.tags || task.tags.length === 0); + notesResult = notesResult.filter(note => !note.tags || note.tags.length === 0); + } + else if (tag) { + tasksResult = tasksResult.filter(task => task.tags && task.tags.includes(tag)); + notesResult = notesResult.filter(note => note.tags && note.tags.includes(tag)); + } + + return Promise.resolve({ tasks: tasksResult, notes: notesResult }); + }; + + const getItemsCalls = () => + (api.getJSON as any).mock.calls.filter((call: any[]) => + (call[0] as string).includes('home/items')); + beforeEach(() => { // Reset mocks and setup default responses vi.clearAllMocks(); localStorage.clear(); (api.getJSON as any).mockImplementation((url: string) => { - if (url.includes('tags')) { + if (url.includes('tasks/tags')) { return Promise.resolve(mockTags); - } else if (url.includes('tasks')) { - return Promise.resolve(mockTasks); - } else if (url.includes('notes')) { - return Promise.resolve(mockNotes); + } + else if (url.includes('home/items')) { + return filterItems(url); } return Promise.resolve([]); }); @@ -218,10 +256,14 @@ describe('Home Component', () => { expect(screen.getByTestId('content-header')).toBeDefined(); expect(screen.getByPlaceholderText('home_input_filter')).toBeDefined(); - - // Check that API calls were made - expect(api.getJSON).toHaveBeenCalledTimes(3); - + + // Wait for tags and windowed items to load + await waitFor(() => { + expect(api.getJSON).toHaveBeenCalledTimes(2); + }); + expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('tasks/tags')); + expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('home/items')); + // Wait for tasks and notes to render await waitFor(() => { expect(screen.getAllByTestId('task-title').length).toBe(2); @@ -368,8 +410,8 @@ describe('Home Component', () => { expect.objectContaining({ completed: true }) ); - // Should reload tasks - expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('tasks')); + // Should reload the current view + expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('home/items')); }); test('archives note', async () => { @@ -405,8 +447,8 @@ describe('Home Component', () => { expect(api.putJSON).toHaveBeenCalledWith(expect.stringContaining('/notes/1/archive'), {}); }); - // Should reload notes - expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('notes')); + // Should reload the current view + expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('home/items')); }); test('opens markdown modal when clicking on note tag', async () => { @@ -652,6 +694,203 @@ describe('Home Component', () => { expect(localStorage.getItem('OPEN_NOTE_ID')).toBeNull(); }); + test('debounces search text into a single backend query', async () => { + await act(async () => { + renderHome(); + }); + + await waitFor(() => { + expect(getItemsCalls().length).toBe(1); + }); + + const searchInput = screen.getByPlaceholderText('home_input_filter'); + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'T' } }); + fireEvent.change(searchInput, { target: { value: 'Ta' } }); + fireEvent.change(searchInput, { target: { value: 'Task' } }); + }); + + await waitFor(() => { + expect(getItemsCalls().length).toBe(2); + }); + + // Only the final text should hit the backend + expect(getItemsCalls()[1][0]).toContain('q=Task'); + }); + + test('clearing the search box re-fetches the default window', async () => { + await act(async () => { + renderHome(); + }); + + await waitFor(() => { + expect(getItemsCalls().length).toBe(1); + }); + + const searchInput = screen.getByPlaceholderText('home_input_filter'); + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'Task' } }); + }); + + await waitFor(() => { + expect(getItemsCalls().length).toBe(2); + }); + expect(getItemsCalls()[1][0]).toContain('q=Task'); + + await act(async () => { + fireEvent.change(searchInput, { target: { value: '' } }); + }); + + await waitFor(() => { + expect(getItemsCalls().length).toBe(3); + }); + // Default window: no query params + expect(getItemsCalls()[2][0]).not.toContain('?'); + + // Full windowed data is shown again + await waitFor(() => { + expect(screen.getAllByTestId('task-title').length).toBe(2); + expect(screen.getAllByTestId('note-title').length).toBe(2); + }); + }); + + test('fires the backend query on mount when a filter is persisted', async () => { + localStorage.setItem('FILTER_TEXT', 'Task 1'); + localStorage.setItem('FILTER_OPTION', 'onlyTasks'); + + await act(async () => { + renderHome(); + }); + + await waitFor(() => { + expect(getItemsCalls().length).toBe(1); + }); + + const url = getItemsCalls()[0][0] as string; + expect(url).toContain('q=Task'); + expect(url).toContain('type=tasks'); + + await waitFor(() => { + expect(screen.getAllByTestId('task-title').length).toBe(1); + expect(screen.queryAllByTestId('note-title').length).toBe(0); + }); + }); + + test('re-runs the active search query after a mutation', async () => { + await act(async () => { + renderHome(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('task-title').length).toBe(2); + }); + + const searchInput = screen.getByPlaceholderText('home_input_filter'); + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'Task 1' } }); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('task-title').length).toBe(1); + }); + + const callsBeforeMutation = getItemsCalls().length; + + const dropdownToggles = screen.getAllByTestId('three-dots-icon'); + await act(async () => { + fireEvent.click(dropdownToggles[0]); + }); + + const dropdownItems = screen.getAllByRole('button'); + const markAsDoneButton = dropdownItems.find( + item => item.textContent === 'task_table_action_done' + ); + await act(async () => { + fireEvent.click(markAsDoneButton!); + }); + + await waitFor(() => { + expect(getItemsCalls().length).toBe(callsBeforeMutation + 1); + }); + + // The refetch must keep the active search query + const lastUrl = getItemsCalls().at(-1)![0] as string; + expect(lastUrl).toContain('q=Task'); + }); + + test('shows the window hint by default', async () => { + await act(async () => { + renderHome(); + }); + + expect(screen.getByTestId('home-view-hint').textContent).toBe('home_window_hint'); + }); + + test('shows the unbounded hint when searching or filtering by tag', async () => { + await act(async () => { + renderHome(); + }); + + const searchInput = screen.getByPlaceholderText('home_input_filter'); + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'Task' } }); + }); + + expect(screen.getByTestId('home-view-hint').textContent).toBe('home_search_hint'); + + await act(async () => { + fireEvent.change(searchInput, { target: { value: '' } }); + }); + + expect(screen.getByTestId('home-view-hint').textContent).toBe('home_window_hint'); + + const dropdownToggle = screen.getByTestId('main-label-selector'); + await act(async () => { + fireEvent.click(dropdownToggle); + }); + + const workTagOption = screen.getByRole('button', { name: /#work/i }); + await act(async () => { + fireEvent.click(workTagOption); + }); + + expect(screen.getByTestId('home-view-hint').textContent).toBe('home_search_hint'); + }); + + test('matches the window hint to the type selection', async () => { + await act(async () => { + renderHome(); + }); + + expect(screen.getByTestId('home-view-hint').textContent).toBe('home_window_hint'); + + const dropdownToggle = screen.getByTestId('main-label-selector'); + await act(async () => { + fireEvent.click(dropdownToggle); + }); + + const onlyTasksOption = screen.getByRole('button', { name: /home_radio_tasks/i }); + await act(async () => { + fireEvent.click(onlyTasksOption); + }); + + expect(screen.getByTestId('home-view-hint').textContent).toBe('home_window_hint_tasks'); + + const onlyNotesOption = screen.getByRole('button', { name: /home_radio_notes/i }); + await act(async () => { + fireEvent.click(onlyNotesOption); + }); + + expect(screen.getByTestId('home-view-hint').textContent).toBe('home_window_hint_notes'); + + const everythingOption = screen.getByRole('button', { name: /home_radio_everything/i }); + await act(async () => { + fireEvent.click(everythingOption); + }); + + expect(screen.getByTestId('home-view-hint').textContent).toBe('home_window_hint'); + }); + /* test('getFirstRows properly formats note preview', async () => { await act(async () => { diff --git a/client/src/constants/english.ts b/client/src/constants/english.ts index 01dd5869..cc20951a 100644 --- a/client/src/constants/english.ts +++ b/client/src/constants/english.ts @@ -69,6 +69,10 @@ const enTranslations = { home_welcome_start: 'Start Your Day, Be', home_welcome_productive: 'Productive', home_input_filter: 'Filter tasks & notes', + home_window_hint: '> Showing tasks and notes updated in the last 24 hours, plus your pending high-priority tasks', + home_window_hint_tasks: '> Showing tasks updated in the last 24 hours, plus your pending high-priority tasks', + home_window_hint_notes: '> Showing notes updated in the last 24 hours', + home_search_hint: '> Showing all matching results, regardless of when they were last updated', home_radio_everything: 'Everything', home_radio_tasks: 'Tasks', home_radio_notes: 'Notes', diff --git a/client/src/constants/portuguese.ts b/client/src/constants/portuguese.ts index b54b7c5b..326a45ab 100644 --- a/client/src/constants/portuguese.ts +++ b/client/src/constants/portuguese.ts @@ -69,6 +69,10 @@ const ptBrTranslations = { home_welcome_start: 'Comece Seu Dia, Seja', home_welcome_productive: 'Produtivo(a)', home_input_filter: 'Filtrar tarefas e notas', + home_window_hint: '> Mostrando tarefas e notas atualizadas nas últimas 24 horas, além das suas tarefas pendentes de alta prioridade', + home_window_hint_tasks: '> Mostrando tarefas atualizadas nas últimas 24 horas, além das suas tarefas pendentes de alta prioridade', + home_window_hint_notes: '> Mostrando notas atualizadas nas últimas 24 horas', + home_search_hint: '> Mostrando todos os resultados correspondentes, independentemente de quando foram atualizados', home_radio_everything: 'Tudo', home_radio_tasks: 'Tarefas', home_radio_notes: 'Notas', diff --git a/client/src/constants/russian.ts b/client/src/constants/russian.ts index e3741365..a49057ec 100644 --- a/client/src/constants/russian.ts +++ b/client/src/constants/russian.ts @@ -69,6 +69,10 @@ const ruTranslations = { home_welcome_start: 'Начните свой день, будьте', home_welcome_productive: 'Продуктивный', home_input_filter: 'Фильтровать задачи и заметки', + home_window_hint: '> Показаны задачи и заметки, обновлённые за последние 24 часа, а также невыполненные приоритетные задачи', + home_window_hint_tasks: '> Показаны задачи, обновлённые за последние 24 часа, а также невыполненные приоритетные задачи', + home_window_hint_notes: '> Показаны заметки, обновлённые за последние 24 часа', + home_search_hint: '> Показаны все подходящие результаты независимо от времени последнего обновления', home_radio_everything: 'Все', home_radio_tasks: 'Задачи', home_radio_notes: 'Заметки', diff --git a/client/src/constants/spanish.ts b/client/src/constants/spanish.ts index e734b91f..4fda3cd1 100644 --- a/client/src/constants/spanish.ts +++ b/client/src/constants/spanish.ts @@ -69,6 +69,10 @@ const esTranslations = { home_welcome_start: 'Comienza Tu Día, Sé', home_welcome_productive: 'Productivo(a)', home_input_filter: 'Filtrar tareas y notas', + home_window_hint: '> Mostrando tareas y notas actualizadas en las últimas 24 horas, además de tus tareas pendientes de alta prioridad', + home_window_hint_tasks: '> Mostrando tareas actualizadas en las últimas 24 horas, además de tus tareas pendientes de alta prioridad', + home_window_hint_notes: '> Mostrando notas actualizadas en las últimas 24 horas', + home_search_hint: '> Mostrando todos los resultados coincidentes, independientemente de cuándo se actualizaron por última vez', home_radio_everything: 'Todo', home_radio_tasks: 'Tareas', home_radio_notes: 'Notas', diff --git a/client/src/styles/custom.scss b/client/src/styles/custom.scss index 0b13772b..ec301a30 100644 --- a/client/src/styles/custom.scss +++ b/client/src/styles/custom.scss @@ -511,3 +511,8 @@ p.search-result-item-title { .dropdown-item.active .badge { box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25); } + +.search-help-text { + margin-left: 5px; + font-size: 0.8rem; +} \ No newline at end of file diff --git a/client/src/types/HomeItemsResponse.ts b/client/src/types/HomeItemsResponse.ts new file mode 100644 index 00000000..f4f1d573 --- /dev/null +++ b/client/src/types/HomeItemsResponse.ts @@ -0,0 +1,9 @@ +import { TaskResponse } from './TaskResponse'; +import { NoteResponse } from './NoteResponse'; + +type HomeItemsResponse = { + tasks: TaskResponse[]; + notes: NoteResponse[]; +}; + +export type { HomeItemsResponse }; diff --git a/client/src/views/Home/index.tsx b/client/src/views/Home/index.tsx index 2a8cfe88..27c3f0cf 100644 --- a/client/src/views/Home/index.tsx +++ b/client/src/views/Home/index.tsx @@ -14,6 +14,7 @@ import { } from 'react-bootstrap'; import { TaskResponse } from '../../types/TaskResponse'; import { NoteResponse } from '../../types/NoteResponse'; +import { HomeItemsResponse } from '../../types/HomeItemsResponse'; import api from '../../api-service/api'; import ApiConfig from '../../api-service/apiConfig'; import { handleDefaultLang } from '../../lang-service/LangHandler'; @@ -31,6 +32,7 @@ import TaskTag from '../../components/TaskTag'; import NoteTitle from '../../components/NoteTitle'; const OPEN_NOTE_ID_KEY = 'OPEN_NOTE_ID'; +const SEARCH_DEBOUNCE_MS = 300; /** * Home page component. @@ -53,8 +55,6 @@ function Home(): React.ReactNode { const [completedTasks, setCompletedTasks] = useState([]); const [notes, setNotes] = useState([]); const [archivedNotes, setArchivedNotes] = useState([]); - const [savedNotes, setSavedNotes] = useState([]); - const [savedTasks, setSavedTasks] = useState([]); const [showDeleteModal, setShowDeleteModal] = useState(false); const [deleteTarget, setDeleteTarget] = useState<{ type: 'task' | 'note'; id: number } | null>(null); @@ -90,7 +90,7 @@ function Home(): React.ReactNode { const toggleTaskCompleted = async (task: TaskResponse): Promise => { try { await api.patchJSON(`${ApiConfig.tasksUrl}/${task.id}`, { completed: !task.completed }); - await loadAllTasks(); + await loadItems(filterText, selectedOption); } catch (e) { handleError(e); @@ -105,7 +105,7 @@ function Home(): React.ReactNode { const deleteTask = async (taskIdParam: number) => { try { await api.deleteNoContent(`${ApiConfig.tasksUrl}/${taskIdParam}`); - await loadAllTasks(); + await loadItems(filterText, selectedOption); } catch (e) { handleError(e); @@ -120,7 +120,7 @@ function Home(): React.ReactNode { const deleteNote = async (noteIdParam: number) => { try { await api.deleteNoContent(`${ApiConfig.notesUrl}/${noteIdParam}`); - await loadAllNotes(); + await loadItems(filterText, selectedOption); } catch (e) { handleError(e); @@ -185,7 +185,7 @@ function Home(): React.ReactNode { const archiveNote = async (noteId: number): Promise => { try { await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/archive`, {}); - await loadAllNotes(); + await loadItems(filterText, selectedOption); } catch (e) { handleError(e); @@ -200,7 +200,7 @@ function Home(): React.ReactNode { const restoreNote = async (noteId: number): Promise => { try { await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/restore`, {}); - await loadAllNotes(); + await loadItems(filterText, selectedOption); } catch (e) { handleError(e); @@ -220,7 +220,7 @@ function Home(): React.ReactNode { try { const action = note.shared ? 'unshare' : 'share'; await api.putJSON(`${ApiConfig.notesUrl}/${note.id}/${action}`, {}); - await loadAllNotes(); + await loadItems(filterText, selectedOption); } catch (e) { handleError(e); @@ -240,120 +240,45 @@ function Home(): React.ReactNode { }; /** - * Apply filters to a given set of tasks and notes, updating displayed state. + * Build the home items URL for the given search text and filter option. * - * @param {string} text - The text to filter by. - * @param {string | undefined} radioFilter - The radio filter option. - * @param {TaskResponse[]} allTasks - The full list of tasks to filter from. - * @param {NoteResponse[]} allNotes - The full list of notes to filter from. + * @param {string} text - The search text. + * @param {string | undefined} option - The selected filter option. + * @returns {string} The items URL with query params, if any. */ - const applyFilter = (text: string, radioFilter: string | undefined, allTasks: TaskResponse[], allNotes: NoteResponse[]): void => { - const activeTasks = allTasks.filter((task: TaskResponse) => !task.completed); - const doneTasks = allTasks.filter((task: TaskResponse) => task.completed); - const { active: activeNotes, archived: archivedNoteList } = partitionNotes(allNotes); - - if (!text && (!radioFilter || radioFilter === 'everything')) { - setNotes([...activeNotes]); - setArchivedNotes([...archivedNoteList]); - setTasks([...activeTasks]); - setCompletedTasks([...doneTasks]); - return; + const buildItemsUrl = (text: string, option: string | undefined): string => { + const params = new URLSearchParams(); + if (text && text.trim().length > 0) { + params.append('q', text.trim()); } - - const tagToFilter = radioFilter?.startsWith('radio_') ? radioFilter.substring(6) : ''; - - if (radioFilter && radioFilter === 'onlyTasks') { - setNotes([]); - setArchivedNotes([]); + if (option === 'onlyTasks') { + params.append('type', 'tasks'); } - else { - let filteredNotes = activeNotes.filter((note: NoteResponse) => { - const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase()); - const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase()); - const anyUrlMatch = note.url?.includes(text.toLowerCase()); - const anyTagMatch = note.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase())); - return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch; - }); - - if (tagToFilter === 'untagged') { - filteredNotes = filteredNotes.filter((note: NoteResponse) => !note.tags || note.tags.length === 0); - } - else if (tagToFilter) { - filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter)); - } - - let filteredArchivedNotes = archivedNoteList.filter((note: NoteResponse) => { - const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase()); - const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase()); - const anyUrlMatch = note.url?.includes(text.toLowerCase()); - const anyTagMatch = note.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase())); - return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch; - }); - - if (tagToFilter === 'untagged') { - filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => !note.tags || note.tags.length === 0); - } - else if (tagToFilter) { - filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter)); - } - - setNotes([...filteredNotes]); - setArchivedNotes([...filteredArchivedNotes]); + else if (option === 'onlyNotes') { + params.append('type', 'notes'); } - - if (radioFilter && radioFilter === 'onlyNotes') { - setTasks([]); - setCompletedTasks([]); - } - else { - let filteredTasks = activeTasks.filter((task: TaskResponse) => { - return task.description.toLowerCase().includes(text.toLowerCase()) - || task.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase())) - || task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0; - }); - - if (tagToFilter === 'untagged') { - filteredTasks = filteredTasks.filter((task: TaskResponse) => !task.tags || task.tags.length === 0); - } - else if (tagToFilter) { - filteredTasks = filteredTasks.filter((task: TaskResponse) => task.tags && task.tags.includes(tagToFilter)); - } - - setTasks([...filteredTasks]); - - let filteredCompletedTasks = doneTasks.filter((task: TaskResponse) => { - return task.description.toLowerCase().includes(text.toLowerCase()) - || task.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase())) - || task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0; - }); - - if (tagToFilter === 'untagged') { - filteredCompletedTasks = filteredCompletedTasks.filter((task: TaskResponse) => !task.tags || task.tags.length === 0); - } - else if (tagToFilter) { - filteredCompletedTasks = filteredCompletedTasks.filter((task: TaskResponse) => task.tags && task.tags.includes(tagToFilter)); - } - - setCompletedTasks([...filteredCompletedTasks]); + else if (option && option.startsWith('radio_')) { + params.append('tag', option.substring(6)); } + const queryString = params.toString(); + return queryString + ? `${ApiConfig.homeUrl}/items?${queryString}` + : `${ApiConfig.homeUrl}/items`; }; /** - * Filter notes by a given text. - */ - const filterTasksAndNotes = (text: string, radioFilter?: string): void => { - setFilterText(text); - applyFilter(text, radioFilter, savedTasks, savedNotes); - }; - - /** - * Load tasks from the server. + * Load home items from the server for the current view: the default window, + * or the active search/tag query. + * + * @param {string} text - The search text. + * @param {string | undefined} option - The selected filter option. */ - const loadAllTasks = async () => { + const loadItems = async (text: string, option: string | undefined): Promise => { try { - const tasksFetched: TaskResponse[] = await api.getJSON(ApiConfig.tasksUrl); - const translated = translateTaskResponse(tasksFetched, i18n.language); - translated.sort((t1, t2) => { + const response: HomeItemsResponse = await api.getJSON(buildItemsUrl(text, option)); + + const translatedTasks = translateTaskResponse(response.tasks ?? [], i18n.language); + translatedTasks.sort((t1, t2) => { if (t1.completed === t2.completed) { if (t1.highPriority === t2.highPriority) { return 0; @@ -368,21 +293,16 @@ function Home(): React.ReactNode { } return 1; }); - setSavedTasks([...translated]); - } - catch (e) { - handleError(e); - } - }; - /** - * Load notes from the server. - */ - const loadAllNotes = async () => { - try { - const notesFetched: NoteResponse[] = await api.getJSON(ApiConfig.notesUrl); - notesFetched.sort((n1, n2) => (n1.id > n2.id) ? -1 : 1); - setSavedNotes([...notesFetched]); + const fetchedNotes = [...(response.notes ?? [])]; + fetchedNotes.sort((n1, n2) => (n1.id > n2.id) ? -1 : 1); + + setTasks(translatedTasks.filter((task: TaskResponse) => !task.completed)); + setCompletedTasks(translatedTasks.filter((task: TaskResponse) => task.completed)); + + const { active, archived } = partitionNotes(fetchedNotes); + setNotes(active); + setArchivedNotes(archived); } catch (e) { handleError(e); @@ -470,6 +390,20 @@ function Home(): React.ReactNode { return t('home_radio_everything'); }; + const isUnboundedView = (): boolean => { + return filterText.trim().length > 0 || selectedOption.startsWith('radio_'); + }; + + const getWindowHintKey = (): string => { + if (selectedOption === 'onlyTasks') { + return 'home_window_hint_tasks'; + } + if (selectedOption === 'onlyNotes') { + return 'home_window_hint_notes'; + } + return 'home_window_hint'; + }; + const getSelectedVariant = (): string => { if (selectedOption === 'everything') { return 'secondary'; @@ -485,20 +419,20 @@ function Home(): React.ReactNode { const handleOptionChange = (value: string): void => { setSelectedOption(value); - filterTasksAndNotes(filterText, value); }; useEffect(() => { handleDefaultLang(user?.lang); setName(user?.name ?? 'User'); loadTags(); - loadAllTasks(); - loadAllNotes(); }, [user]); useEffect(() => { - applyFilter(filterText, selectedOption, savedTasks, savedNotes); - }, [savedTasks, savedNotes, filterText, selectedOption]); + const timer = setTimeout(() => { + void loadItems(filterText, selectedOption); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [filterText, selectedOption, user]); useEffect(() => { const openNoteId = localStorage.getItem(OPEN_NOTE_ID_KEY); @@ -544,7 +478,7 @@ function Home(): React.ReactNode { placeholder={t('home_input_filter')} value={filterText} onChange={(e: React.ChangeEvent) => - filterTasksAndNotes(e.target.value, selectedOption)} + setFilterText(e.target.value)} className="border-0" style={{ borderTopRightRadius: 0, @@ -642,6 +576,12 @@ function Home(): React.ReactNode { + + {isUnboundedView() ? t('home_search_hint') : t(getWindowHintKey())} + diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/HomeController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/HomeController.java index 43464579..e7999276 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/HomeController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/HomeController.java @@ -1,9 +1,11 @@ package br.com.tasknoteapp.server.controller; +import br.com.tasknoteapp.server.response.HomeItemsResponse; import br.com.tasknoteapp.server.service.HomeService; import java.util.List; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** This class provides resources to handle home requests by the client. */ @@ -26,4 +28,21 @@ public HomeController(HomeService homeService) { public List getTasksTags() { return homeService.getTopTasksTag(); } + + /** + * Get the Home items payload. Without search or tag filters it returns the default window; + * with filters it runs an unbounded query composing all params with AND semantics. + * + * @param searchTerm Optional text to search by. + * @param tag Optional tag name to filter by ("untagged" matches items without tags). + * @param type Optional item type: all, tasks or notes. + * @return {@link HomeItemsResponse} with the tasks and notes found. + */ + @GetMapping("/items") + public HomeItemsResponse getHomeItems( + @RequestParam(name = "q", required = false) String searchTerm, + @RequestParam(name = "tag", required = false) String tag, + @RequestParam(name = "type", required = false) String type) { + return homeService.getHomeItems(searchTerm, tag, type); + } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/repository/NoteRepository.java b/server/src/main/java/br/com/tasknoteapp/server/repository/NoteRepository.java index 4a05019c..5c4d2340 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/repository/NoteRepository.java +++ b/server/src/main/java/br/com/tasknoteapp/server/repository/NoteRepository.java @@ -3,6 +3,7 @@ import br.com.tasknoteapp.server.entity.Note; import java.util.List; import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; @@ -13,8 +14,13 @@ public class NoteRepository { private final NamedParameterJdbcTemplate jdbcTemplate; - public NoteRepository(NamedParameterJdbcTemplate jdbcTemplate) { + private final long homeWindowMinutes; + + public NoteRepository( + NamedParameterJdbcTemplate jdbcTemplate, + @Value ("${br.com.tasknote.server.home-window-minutes:1440}") long homeWindowMinutes) { this.jdbcTemplate = jdbcTemplate; + this.homeWindowMinutes = homeWindowMinutes; } /** @@ -214,6 +220,75 @@ OR upper(description) like upper(concat('%', :searchTerm, '%')) return jdbcTemplate.query(sql, params, new NoteRowMapper()); } + /** + * Find Notes for the Home view: either the default window (touched in the last 24h) or an + * unbounded search/tag query. + * + * @param userId The User ID to fetch by. + * @param searchTerm Optional text to search in title, description, url and tags. + * @param tag Optional tag name to filter by ("untagged" matches notes without tags). + * @param windowedOnly When true, restrict to notes touched in the last 24h. + * @return List of Notes found. + */ + public List findHomeNotes( + Long userId, String searchTerm, String tag, boolean windowedOnly) { + StringBuilder sql = + new StringBuilder( + """ + SELECT DISTINCT n.id, + n.user_id, + n.description, + n.title, + n.last_update, + n.shared, + n.share_token, + n.archived + FROM tasknote.notes n + LEFT JOIN tasknote.note_tags nt ON nt.note_id = n.id + LEFT JOIN tasknote.tags tg ON tg.id = nt.tag_id + LEFT JOIN tasknote.note_urls nu ON nu.note_id = n.id + WHERE n.user_id = :userId + """); + + MapSqlParameterSource params = new MapSqlParameterSource().addValue("userId", userId); + + if (windowedOnly) { + sql.append("\n AND n.last_update >= :windowStart\n"); + params.addValue( + "windowStart", java.time.LocalDateTime.now().minusMinutes(homeWindowMinutes)); + } else { + if (searchTerm != null && !searchTerm.isBlank()) { + sql.append( + """ + \ + AND ( + UPPER(n.title) LIKE UPPER(CONCAT('%', :searchTerm, '%')) + OR UPPER(n.description) LIKE UPPER(CONCAT('%', :searchTerm, '%')) + OR UPPER(nu.url) LIKE UPPER(CONCAT('%', :searchTerm, '%')) + OR UPPER(tg.name) LIKE UPPER(CONCAT('%', :searchTerm, '%')) + ) + """); + params.addValue("searchTerm", searchTerm); + } + if (tag != null && !tag.isBlank()) { + if ("untagged".equals(tag)) { + sql.append( + """ + \ + AND NOT EXISTS ( + SELECT 1 FROM tasknote.note_tags nt2 WHERE nt2.note_id = n.id + ) + """); + } else { + sql.append("\n AND tg.name = :tag\n"); + params.addValue("tag", tag); + } + } + } + + return jdbcTemplate.query(sql.toString(), params, new NoteRowMapper()); + } + class NoteRowMapper implements org.springframework.jdbc.core.RowMapper { @Override public Note mapRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { diff --git a/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java b/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java index d312193b..91efa6a5 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java +++ b/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java @@ -310,6 +310,54 @@ public Optional findByUserIdAndName(Long userId, String name) { return tags.stream().findFirst(); } + /** + * Find all distinct tag names for a User ID. + * + * @param userId The User ID to fetch by. + * @return List of distinct tag names. + */ + public List findAllTagNamesByUserId(Long userId) { + String sql = + """ + SELECT DISTINCT name + FROM tasknote.tags + WHERE user_id = :userId + """; + + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("userId", userId); + + return jdbcTemplate.query(sql, params, (rs, rowNum) -> rs.getString("name")); + } + + /** + * Check if the user has any task or note without tags. + * + * @param userId The User ID to check by. + * @return true if there is at least one untagged task or note, false otherwise. + */ + public boolean userHasUntaggedItems(Long userId) { + String sql = + """ + SELECT EXISTS ( + SELECT 1 FROM tasknote.tasks t + WHERE t.user_id = :userId + AND NOT EXISTS (SELECT 1 FROM tasknote.task_tags tt WHERE tt.task_id = t.id) + ) OR EXISTS ( + SELECT 1 FROM tasknote.notes n + WHERE n.user_id = :userId + AND NOT EXISTS (SELECT 1 FROM tasknote.note_tags nt WHERE nt.note_id = n.id) + ) AS has_untagged + """; + + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("userId", userId); + + Boolean hasUntagged = jdbcTemplate.queryForObject(sql, params, Boolean.class); + + return Boolean.TRUE.equals(hasUntagged); + } + /** * Deletes all orphaned tags given a User ID (deleting from * both Notes and Tasks). diff --git a/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java b/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java index 2260d979..409ae291 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java +++ b/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; @@ -15,8 +16,13 @@ public class TaskRepository { private final NamedParameterJdbcTemplate jdbcTemplate; - public TaskRepository(NamedParameterJdbcTemplate jdbcTemplate) { + private final long homeWindowMinutes; + + public TaskRepository( + NamedParameterJdbcTemplate jdbcTemplate, + @Value ("${br.com.tasknote.server.home-window-minutes:1440}") long homeWindowMinutes) { this.jdbcTemplate = jdbcTemplate; + this.homeWindowMinutes = homeWindowMinutes; } /** @@ -221,6 +227,82 @@ OR UPPER(tu.url) LIKE UPPER(CONCAT('%', :searchTerm, '%')) return jdbcTemplate.query(sql, params, new TaskRowMapper()); } + /** + * Find Tasks for the Home view: either the default window (recently touched or high-priority + * incomplete) or an unbounded search/tag query. + * + * @param userId The User ID to fetch by. + * @param searchTerm Optional text to search in description, tags and urls. + * @param tag Optional tag name to filter by ("untagged" matches tasks without tags). + * @param windowedOnly When true, restrict to the default window (last 24h or high-priority + * incomplete). + * @return List of Tasks found. + */ + public List findHomeTasks( + Long userId, String searchTerm, String tag, boolean windowedOnly) { + StringBuilder sql = + new StringBuilder( + """ + SELECT DISTINCT t.id, + t.user_id, + t.description, + t.completed, + t.last_update, + t.due_date, + t.due_date_notify, + t.due_date_notify_sent, + t.high_priority + FROM tasknote.tasks t + LEFT JOIN tasknote.task_tags tt ON tt.task_id = t.id + LEFT JOIN tasknote.tags tg ON tg.id = tt.tag_id + LEFT JOIN tasknote.task_url tu ON tu.task_id = t.id + WHERE t.user_id = :userId + """); + + MapSqlParameterSource params = new MapSqlParameterSource().addValue("userId", userId); + + if (windowedOnly) { + sql.append( + """ + \ + AND ( + t.last_update >= :windowStart + OR (t.high_priority = true AND t.completed = false) + ) + """); + params.addValue("windowStart", LocalDateTime.now().minusMinutes(homeWindowMinutes)); + } else { + if (searchTerm != null && !searchTerm.isBlank()) { + sql.append( + """ + \ + AND ( + UPPER(t.description) LIKE UPPER(CONCAT('%', :searchTerm, '%')) + OR UPPER(tg.name) LIKE UPPER(CONCAT('%', :searchTerm, '%')) + OR UPPER(tu.url) LIKE UPPER(CONCAT('%', :searchTerm, '%')) + ) + """); + params.addValue("searchTerm", searchTerm); + } + if (tag != null && !tag.isBlank()) { + if ("untagged".equals(tag)) { + sql.append( + """ + \ + AND NOT EXISTS ( + SELECT 1 FROM tasknote.task_tags tt2 WHERE tt2.task_id = t.id + ) + """); + } else { + sql.append("\n AND tg.name = :tag\n"); + params.addValue("tag", tag); + } + } + } + + return jdbcTemplate.query(sql.toString(), params, new TaskRowMapper()); + } + class TaskRowMapper implements org.springframework.jdbc.core.RowMapper { @Override public Task mapRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/HomeItemsResponse.java b/server/src/main/java/br/com/tasknoteapp/server/response/HomeItemsResponse.java new file mode 100644 index 00000000..d352d9df --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/response/HomeItemsResponse.java @@ -0,0 +1,6 @@ +package br.com.tasknoteapp.server.response; + +import java.util.List; + +/** This record represents the Home aggregated payload with tasks and notes. */ +public record HomeItemsResponse(List tasks, List notes) {} diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java b/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java index 138e1311..db37fbed 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java @@ -2,6 +2,7 @@ import br.com.tasknoteapp.server.entity.User; import br.com.tasknoteapp.server.repository.TagRepository; +import br.com.tasknoteapp.server.response.HomeItemsResponse; import br.com.tasknoteapp.server.response.NoteResponse; import br.com.tasknoteapp.server.response.TaskResponse; import br.com.tasknoteapp.server.util.AuthUtil; @@ -19,10 +20,14 @@ public class HomeService { private static final Logger logger = LoggerFactory.getLogger(HomeService.class); + private static final String UNTAGGED = "untagged"; + private final TaskService taskService; private final NoteService noteService; + private final TagRepository tagRepository; + private final AuthService authService; private final AuthUtil authUtil; @@ -44,6 +49,7 @@ public HomeService( AuthUtil authUtil) { this.taskService = taskService; this.noteService = noteService; + this.tagRepository = tagRepository; this.authService = authService; this.authUtil = authUtil; } @@ -57,19 +63,10 @@ public List getTopTasksTag() { User user = getCurrentUser(); logger.info("Getting all tags for user ID {}", user.getId()); - List tasks = taskService.getTasksByFilter("all"); - List notes = noteService.getAllNotes(); - - Set tagSet = new HashSet<>(); - tasks.forEach((t) -> tagSet.addAll(t.tags())); - notes.forEach((n) -> tagSet.addAll(n.tags())); + Set tagSet = new HashSet<>(tagRepository.findAllTagNamesByUserId(user.getId())); - boolean hasUntagged = - tasks.stream().anyMatch(task -> task.tags().isEmpty()) - || notes.stream().anyMatch(note -> note.tags().isEmpty()); - - if (hasUntagged) { - tagSet.add("untagged"); + if (tagRepository.userHasUntaggedItems(user.getId())) { + tagSet.add(UNTAGGED); } List tags = tagSet @@ -82,6 +79,43 @@ public List getTopTasksTag() { return tags; } + /** + * Get the Home items payload. Without search or tag filters it returns the default window + * (items touched in the last 24h plus high-priority incomplete tasks); with filters it runs an + * unbounded query composing all params with AND semantics. + * + * @param searchTerm Optional text to search by. + * @param tag Optional tag name to filter by ("untagged" matches items without tags). + * @param type Optional item type: all, tasks or notes. + * @return {@link HomeItemsResponse} with the tasks and notes found. + */ + public HomeItemsResponse getHomeItems(String searchTerm, String tag, String type) { + User user = getCurrentUser(); + logger.info( + "Getting home items for user ID {}, q: {}, tag: {}, type: {}", + user.getId(), searchTerm, tag, type); + + boolean hasSearch = searchTerm != null && !searchTerm.isBlank(); + boolean hasTag = tag != null && !tag.isBlank(); + boolean windowed = !hasSearch && !hasTag; + + boolean includeTasks = type == null || type.isBlank() || "all".equals(type) + || "tasks".equals(type); + boolean includeNotes = type == null || type.isBlank() || "all".equals(type) + || "notes".equals(type); + + List tasks = + includeTasks + ? taskService.getHomeTasks(searchTerm, tag, windowed) + : List.of(); + List notes = + includeNotes + ? noteService.getHomeNotes(searchTerm, tag, windowed) + : List.of(); + + return new HomeItemsResponse(tasks, notes); + } + private User getCurrentUser() { Optional currentUserEmail = authUtil.getCurrentUserEmail(); String email = currentUserEmail.orElseThrow(); diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java b/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java index 82173a1d..31cf21e6 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java @@ -88,6 +88,26 @@ public List getAllNotes() { return buildNoteResponse(notes, user.getId()); } + /** + * Get notes for the Home view, either the default window or an unbounded search/tag query. + * + * @param searchTerm Optional text to search in title, description, url and tags. + * @param tag Optional tag name to filter by ("untagged" matches notes without tags). + * @param windowedOnly When true, restrict to notes touched in the last 24h. + * @return {@link List} of {@link NoteResponse} with found records or an empty list. + */ + @Transactional + public List getHomeNotes(String searchTerm, String tag, boolean windowedOnly) { + User user = getCurrentUser(); + + logger.info("Get home notes to user ID {}, windowed: {}", user.getId(), windowedOnly); + + List notes = noteRepository.findHomeNotes(user.getId(), searchTerm, tag, windowedOnly); + logger.info("{} home notes found!", notes.size()); + + return buildNoteResponse(notes, user.getId()); + } + /** * Get a note by its id. * diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java index 212a8aed..e4830499 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java @@ -295,6 +295,26 @@ public List searchTasks(String searchTerm) { return buildTaskResponse(tasks, user.getId()); } + /** + * Get tasks for the Home view, either the default window or an unbounded search/tag query. + * + * @param searchTerm Optional text to search in description, tags and urls. + * @param tag Optional tag name to filter by ("untagged" matches tasks without tags). + * @param windowedOnly When true, restrict to the default window (last 24h or high-priority + * incomplete). + * @return {@link List} of {@link TaskResponse} with found records or an empty list. + */ + @Transactional + public List getHomeTasks(String searchTerm, String tag, boolean windowedOnly) { + User user = getCurrentUser(); + logger.info("Get home tasks to user ID {}, windowed: {}", user.getId(), windowedOnly); + + List tasks = taskRepository.findHomeTasks(user.getId(), searchTerm, tag, windowedOnly); + logger.info("{} home tasks found!", tasks.size()); + + return buildTaskResponse(tasks, user.getId()); + } + /** * Get tasks by a given filter. * diff --git a/server/src/main/resources/META-INF/native-image/reachability-metadata.json b/server/src/main/resources/META-INF/native-image/reachability-metadata.json index e7f833a8..ac6c90e0 100644 --- a/server/src/main/resources/META-INF/native-image/reachability-metadata.json +++ b/server/src/main/resources/META-INF/native-image/reachability-metadata.json @@ -203,6 +203,18 @@ "parameterTypes": [ "br.com.tasknoteapp.server.service.HomeService" ] + }, + { + "name": "getHomeItems", + "parameterTypes": [ + "java.lang.String", + "java.lang.String", + "java.lang.String" + ] + }, + { + "name": "getTasksTags", + "parameterTypes": [] } ] }, @@ -215,10 +227,6 @@ "br.com.tasknoteapp.server.service.NoteService" ] }, - { - "name": "getAllNotes", - "parameterTypes": [] - }, { "name": "postNotes", "parameterTypes": [ @@ -244,12 +252,6 @@ { "name": "", "parameterTypes": [] - }, - { - "name": "handleBadRequestException", - "parameterTypes": [ - "br.com.tasknoteapp.server.exception.BaseBadRequestException" - ] } ] }, @@ -262,10 +264,6 @@ "br.com.tasknoteapp.server.service.TaskService" ] }, - { - "name": "getAllTasks", - "parameterTypes": [] - }, { "name": "postTasks", "parameterTypes": [ @@ -282,6 +280,16 @@ "parameterTypes": [ "br.com.tasknoteapp.server.service.AuthService" ] + }, + { + "name": "getCurrentUser", + "parameterTypes": [] + }, + { + "name": "patchUserInfo", + "parameterTypes": [ + "br.com.tasknoteapp.server.request.UserPatchRequest" + ] } ] }, @@ -361,13 +369,17 @@ { "name": "", "parameterTypes": [ - "org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate" + "org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate", + "long" ] }, { - "name": "findAllByUserId", + "name": "findHomeNotes", "parameterTypes": [ - "java.lang.Long" + "java.lang.Long", + "java.lang.String", + "java.lang.String", + "boolean" ] }, { @@ -403,12 +415,6 @@ "parameterTypes": [ "java.lang.Long" ] - }, - { - "name": "save", - "parameterTypes": [ - "br.com.tasknoteapp.server.entity.NoteUrl" - ] } ] }, @@ -452,6 +458,12 @@ "java.util.List" ] }, + { + "name": "findAllTagNamesByUserId", + "parameterTypes": [ + "java.lang.Long" + ] + }, { "name": "findByUserIdAndName", "parameterTypes": [ @@ -473,6 +485,12 @@ "br.com.tasknoteapp.server.entity.Tag", "java.lang.Long" ] + }, + { + "name": "userHasUntaggedItems", + "parameterTypes": [ + "java.lang.Long" + ] } ] }, @@ -493,13 +511,17 @@ { "name": "", "parameterTypes": [ - "org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate" + "org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate", + "long" ] }, { - "name": "findAllByUserId", + "name": "findHomeTasks", "parameterTypes": [ - "java.lang.Long" + "java.lang.Long", + "java.lang.String", + "java.lang.String", + "boolean" ] }, { @@ -535,12 +557,6 @@ "parameterTypes": [ "java.lang.Long" ] - }, - { - "name": "saveAll", - "parameterTypes": [ - "java.util.List" - ] } ] }, @@ -667,14 +683,31 @@ ] }, { - "type": "br.com.tasknoteapp.server.response.FieldIssueResponse", + "type": "br.com.tasknoteapp.server.request.UserPatchRequest", "methods": [ { - "name": "fieldMessage", + "name": "", + "parameterTypes": [ + "java.lang.String", + "java.lang.String", + "java.lang.String", + "java.lang.String", + "java.lang.String", + "java.lang.String", + "java.lang.String" + ] + } + ] + }, + { + "type": "br.com.tasknoteapp.server.response.HomeItemsResponse", + "methods": [ + { + "name": "notes", "parameterTypes": [] }, { - "name": "fieldName", + "name": "tasks", "parameterTypes": [] } ] @@ -720,6 +753,9 @@ } ] }, + { + "type": "br.com.tasknoteapp.server.response.NoteResponse[]" + }, { "type": "br.com.tasknoteapp.server.response.TaskResponse", "methods": [ @@ -762,7 +798,10 @@ ] }, { - "type": "br.com.tasknoteapp.server.response.UserResponseWithToken", + "type": "br.com.tasknoteapp.server.response.TaskResponse[]" + }, + { + "type": "br.com.tasknoteapp.server.response.UserResponse", "methods": [ { "name": "admin", @@ -784,10 +823,6 @@ "name": "inactivatedAt", "parameterTypes": [] }, - { - "name": "lang", - "parameterTypes": [] - }, { "name": "lastLogin", "parameterTypes": [] @@ -797,7 +832,7 @@ "parameterTypes": [] }, { - "name": "token", + "name": "theme", "parameterTypes": [] }, { @@ -807,14 +842,46 @@ ] }, { - "type": "br.com.tasknoteapp.server.response.ValidationExceptionResponse", + "type": "br.com.tasknoteapp.server.response.UserResponseWithToken", "methods": [ { - "name": "getErrorMessage", + "name": "admin", + "parameterTypes": [] + }, + { + "name": "createdAt", + "parameterTypes": [] + }, + { + "name": "email", + "parameterTypes": [] + }, + { + "name": "gravatarImageUrl", + "parameterTypes": [] + }, + { + "name": "inactivatedAt", + "parameterTypes": [] + }, + { + "name": "lang", + "parameterTypes": [] + }, + { + "name": "lastLogin", + "parameterTypes": [] + }, + { + "name": "name", + "parameterTypes": [] + }, + { + "name": "token", "parameterTypes": [] }, { - "name": "getFields", + "name": "userId", "parameterTypes": [] } ] @@ -856,6 +923,16 @@ "java.lang.String" ] }, + { + "name": "getCurrentUserResponse", + "parameterTypes": [] + }, + { + "name": "patchUserInfo", + "parameterTypes": [ + "br.com.tasknoteapp.server.request.UserPatchRequest" + ] + }, { "name": "signInUser", "parameterTypes": [ @@ -934,8 +1011,12 @@ ] }, { - "name": "getAllNotes", - "parameterTypes": [] + "name": "getHomeNotes", + "parameterTypes": [ + "java.lang.String", + "java.lang.String", + "boolean" + ] } ] }, @@ -970,8 +1051,12 @@ ] }, { - "name": "getAllTasks", - "parameterTypes": [] + "name": "getHomeTasks", + "parameterTypes": [ + "java.lang.String", + "java.lang.String", + "boolean" + ] } ] }, @@ -1889,9 +1974,6 @@ { "type": "java.util.ImmutableCollections$AbstractImmutableList" }, - { - "type": "java.util.ImmutableCollections$List12" - }, { "type": "java.util.ImmutableCollections$ListN" }, @@ -6405,6 +6487,9 @@ { "type": "org.springframework.web.bind.annotation.RequestMethod[]" }, + { + "type": "org.springframework.web.bind.annotation.RequestParam" + }, { "type": "org.springframework.web.bind.annotation.ResponseBody" }, @@ -7178,15 +7263,6 @@ } ] }, - { - "type": "sun.text.resources.FormatData" - }, - { - "type": "sun.text.resources.FormatData_en" - }, - { - "type": "sun.text.resources.JavaTimeSupplementary" - }, { "type": "sun.text.resources.cldr.FormatData" }, @@ -7347,6 +7423,13 @@ ] } }, + { + "type": { + "proxy": [ + "org.springframework.web.bind.annotation.RequestParam" + ] + } + }, { "type": { "lambda": { @@ -7734,6 +7817,9 @@ { "glob": "db/migration/V202609171142__drop_tag_from_notes_table.sql" }, + { + "glob": "db/migration/V202609241200__add_last_update_indexes.sql" + }, { "glob": "git.properties" }, diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index cdb0c635..18ef59f0 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -4,6 +4,7 @@ br: server: jwt-secret: ${SECURITY_KEY} target-env: ${TARGET_ENV:development} + home-window-minutes: ${HOME_WINDOW_MINUTES:1440} cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:https://flattop-depth-dropper.ngrok-free.dev} logging: diff --git a/server/src/main/resources/db/migration/V202609241200__add_last_update_indexes.sql b/server/src/main/resources/db/migration/V202609241200__add_last_update_indexes.sql new file mode 100644 index 00000000..d2db5646 --- /dev/null +++ b/server/src/main/resources/db/migration/V202609241200__add_last_update_indexes.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS idx_tasks_last_update ON tasknote.tasks (last_update); + +CREATE INDEX IF NOT EXISTS idx_notes_last_update ON tasknote.notes (last_update); diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java index e225e7fc..e9828d20 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java @@ -6,6 +6,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import br.com.tasknoteapp.server.response.HomeItemsResponse; import br.com.tasknoteapp.server.service.HomeService; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -73,4 +74,53 @@ void getTasksTags_notAuthorized_shouldFail() throws Exception { .andExpect(status().isUnauthorized()) .andReturn(); } + + @Test + @DisplayName("Get home items with no params should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void getHomeItems_noParams_shouldSucceed() throws Exception { + when(homeService.getHomeItems(null, null, null)) + .thenReturn(new HomeItemsResponse(List.of(), List.of())); + + mockMvc + .perform( + get("/rest/home/items") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tasks", org.hamcrest.Matchers.empty())) + .andExpect(jsonPath("$.notes", org.hamcrest.Matchers.empty())) + .andReturn(); + } + + @Test + @DisplayName("Get home items with composed params should pass them to the service") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void getHomeItems_withParams_shouldSucceed() throws Exception { + when(homeService.getHomeItems("foo", "work", "tasks")) + .thenReturn(new HomeItemsResponse(List.of(), List.of())); + + mockMvc + .perform( + get("/rest/home/items?q=foo&tag=work&type=tasks") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + } + + @Test + @DisplayName("Get home items not authorized it should fail") + void getHomeItems_notAuthorized_shouldFail() throws Exception { + mockMvc + .perform( + get("/rest/home/items") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andReturn(); + } } diff --git a/server/src/test/java/br/com/tasknoteapp/server/repository/HomeItemsRepositoryIntTest.java b/server/src/test/java/br/com/tasknoteapp/server/repository/HomeItemsRepositoryIntTest.java new file mode 100644 index 00000000..20e35561 --- /dev/null +++ b/server/src/test/java/br/com/tasknoteapp/server/repository/HomeItemsRepositoryIntTest.java @@ -0,0 +1,176 @@ +package br.com.tasknoteapp.server.repository; + +import br.com.tasknoteapp.server.entity.Note; +import br.com.tasknoteapp.server.entity.Task; +import br.com.tasknoteapp.server.entity.User; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.jdbc.Sql; + +@RepositoryIntTest +@Sql(scripts = {"classpath:sql/HomeItemsRepositoryTest.sql"}) +class HomeItemsRepositoryIntTest { + + @Autowired private TaskRepository taskRepository; + + @Autowired private NoteRepository noteRepository; + + @Autowired private TagRepository tagRepository; + + @Autowired private UserRepository userRepository; + + @Test + @DisplayName("Windowed tasks should include recent and high-priority incomplete only") + void findHomeTasks_windowed_shouldApplyWindowRules() { + List tasks = taskRepository.findHomeTasks(getUserId(), null, null, true); + + List descriptions = tasks.stream().map(Task::description).toList(); + + Assertions.assertTrue(descriptions.contains("Recent task")); + Assertions.assertTrue(descriptions.contains("Old HP incomplete task")); + Assertions.assertTrue(descriptions.contains("Boundary recent completed task")); + Assertions.assertFalse(descriptions.contains("Old task")); + Assertions.assertFalse(descriptions.contains("Old HP completed task")); + Assertions.assertFalse(descriptions.contains("Untagged old task")); + } + + @Test + @DisplayName("Windowed notes should include recent and just-archived notes only") + void findHomeNotes_windowed_shouldApplyWindowRules() { + List notes = noteRepository.findHomeNotes(getUserId(), null, null, true); + + List titles = notes.stream().map(Note::title).toList(); + + Assertions.assertTrue(titles.contains("Recent note")); + Assertions.assertTrue(titles.contains("Just archived note")); + Assertions.assertFalse(titles.contains("Old note")); + Assertions.assertFalse(titles.contains("Old archived note")); + Assertions.assertFalse(titles.contains("Untagged old note")); + } + + @Test + @DisplayName("Search should match description and include completed tasks") + void findHomeTasks_searchTerm_shouldMatchDescriptionIncludingCompleted() { + List tasks = taskRepository.findHomeTasks(getUserId(), "old hp", null, false); + + List descriptions = tasks.stream().map(Task::description).toList(); + + Assertions.assertEquals(2, descriptions.size()); + Assertions.assertTrue(descriptions.contains("Old HP incomplete task")); + Assertions.assertTrue(descriptions.contains("Old HP completed task")); + } + + @Test + @DisplayName("Search should match task tags") + void findHomeTasks_searchTerm_shouldMatchTags() { + List tasks = taskRepository.findHomeTasks(getUserId(), "old-tag", null, false); + + Assertions.assertEquals(1, tasks.size()); + Assertions.assertEquals("Old task", tasks.getFirst().description()); + } + + @Test + @DisplayName("Search should match task urls") + void findHomeTasks_searchTerm_shouldMatchUrls() { + List tasks = + taskRepository.findHomeTasks(getUserId(), "searchable-task-url", null, false); + + Assertions.assertEquals(1, tasks.size()); + Assertions.assertEquals("Old task", tasks.getFirst().description()); + } + + @Test + @DisplayName("Search should match note title, description, url and include archived") + void findHomeNotes_searchTerm_shouldMatchFieldsIncludingArchived() { + List byTitle = noteRepository.findHomeNotes(getUserId(), "old note", null, false); + List titles = byTitle.stream().map(Note::title).toList(); + Assertions.assertTrue(titles.contains("Old note")); + Assertions.assertTrue(titles.contains("Old note with url")); + Assertions.assertTrue(titles.contains("Untagged old note")); + Assertions.assertTrue(titles.contains("Tagged old note")); + + List archived = noteRepository.findHomeNotes(getUserId(), "archived", null, false); + List archivedTitles = archived.stream().map(Note::title).toList(); + Assertions.assertTrue(archivedTitles.contains("Old archived note")); + Assertions.assertTrue(archivedTitles.contains("Just archived note")); + + List byUrl = + noteRepository.findHomeNotes(getUserId(), "searchable-note-url", null, false); + Assertions.assertEquals(1, byUrl.size()); + Assertions.assertEquals("Old note with url", byUrl.getFirst().title()); + + List byTag = noteRepository.findHomeNotes(getUserId(), "home-tag", null, false); + Assertions.assertEquals(1, byTag.size()); + Assertions.assertEquals("Tagged old note", byTag.getFirst().title()); + } + + @Test + @DisplayName("Tag filter should return only items with the given tag, unbounded") + void findHomeItems_tagFilter_shouldMatchTag() { + List tasks = taskRepository.findHomeTasks(getUserId(), null, "home-tag", false); + Assertions.assertEquals(1, tasks.size()); + Assertions.assertEquals("Recent task", tasks.getFirst().description()); + + List notes = noteRepository.findHomeNotes(getUserId(), null, "home-tag", false); + Assertions.assertEquals(1, notes.size()); + Assertions.assertEquals("Tagged old note", notes.getFirst().title()); + } + + @Test + @DisplayName("Untagged filter should return only items without tags") + void findHomeItems_untaggedFilter_shouldMatchItemsWithoutTags() { + List tasks = taskRepository.findHomeTasks(getUserId(), null, "untagged", false); + List descriptions = tasks.stream().map(Task::description).toList(); + Assertions.assertTrue(descriptions.contains("Old HP incomplete task")); + Assertions.assertTrue(descriptions.contains("Old HP completed task")); + Assertions.assertTrue(descriptions.contains("Boundary recent completed task")); + Assertions.assertTrue(descriptions.contains("Untagged old task")); + Assertions.assertFalse(descriptions.contains("Recent task")); + Assertions.assertFalse(descriptions.contains("Old task")); + + List notes = noteRepository.findHomeNotes(getUserId(), null, "untagged", false); + List titles = notes.stream().map(Note::title).toList(); + Assertions.assertTrue(titles.contains("Untagged old note")); + Assertions.assertTrue(titles.contains("Old note")); + Assertions.assertFalse(titles.contains("Tagged old note")); + } + + @Test + @DisplayName("Composed search and tag params should apply AND semantics") + void findHomeItems_composedParams_shouldApplyAnd() { + List notes = noteRepository.findHomeNotes(getUserId(), "old", "home-tag", false); + Assertions.assertEquals(1, notes.size()); + Assertions.assertEquals("Tagged old note", notes.getFirst().title()); + + List tasks = taskRepository.findHomeTasks(getUserId(), "old", "home-tag", false); + Assertions.assertTrue(tasks.isEmpty()); + } + + @Test + @DisplayName("Tag names should come straight from the tags table") + void findAllTagNamesByUserId_shouldReturnDistinctNames() { + List names = tagRepository.findAllTagNamesByUserId(getUserId()); + + Assertions.assertTrue(names.contains("home-tag")); + Assertions.assertTrue(names.contains("old-tag")); + } + + @Test + @DisplayName("Untagged detection should reflect items without tags") + void userHasUntaggedItems_shouldDetectUntagged() { + Assertions.assertTrue(tagRepository.userHasUntaggedItems(getUserId())); + Assertions.assertFalse(tagRepository.userHasUntaggedItems(-1L)); + } + + private Long getUserId() { + Optional user = userRepository.findByEmail("home-items@domain.com"); + if (user.isPresent()) { + return user.get().getId(); + } + return 1L; + } +} diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java index ed77344b..7b2f64c2 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java @@ -1,10 +1,12 @@ package br.com.tasknoteapp.server.service; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; -import br.com.tasknoteapp.server.entity.Tag; import br.com.tasknoteapp.server.entity.User; import br.com.tasknoteapp.server.repository.TagRepository; +import br.com.tasknoteapp.server.response.HomeItemsResponse; +import br.com.tasknoteapp.server.response.NoteResponse; import br.com.tasknoteapp.server.response.TaskResponse; import br.com.tasknoteapp.server.util.AuthUtil; import java.util.List; @@ -15,6 +17,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -41,8 +44,8 @@ private User mockUser() { User user = new User(); user.setId(1L); user.setEmail("user@test.com"); - when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of("user@test.com")); - when(authService.findByEmail("user@test.com")).thenReturn(Optional.of(user)); + lenient().when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of("user@test.com")); + lenient().when(authService.findByEmail("user@test.com")).thenReturn(Optional.of(user)); return user; } @@ -50,44 +53,22 @@ private User mockUser() { @DisplayName("Get tasks tags should return all tags ordered alphabetically") void getTopTasksTag_shouldReturnAllTagsAlphabetically() { User user = mockUser(); - Tag tag1 = new Tag(null, "tag1", user.getId()); - Tag tag2 = new Tag(null, "tag2", user.getId()); - Tag tag3 = new Tag(null, "tag3", user.getId()); - Tag tag4 = new Tag(null, "tag4", user.getId()); - Tag tag5 = new Tag(null, "tag5", user.getId()); - Tag tag6 = new Tag(null, "tag6", user.getId()); - Tag tag7 = new Tag(null, "atag", user.getId()); - Tag tag8 = new Tag(null, "dev", user.getId()); - - List tags = List.of(tag1.name(), tag2.name(), tag3.name(), tag4.name(), tag5.name(), - tag6.name(), tag7.name(), tag8.name()); - TaskResponse task1 = - new TaskResponse(1L, false, "Task 1", false, null, null, null, tags, List.of()); - when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1)); - when(noteService.getAllNotes()).thenReturn(List.of()); - - // List taskTags = List.of( - // new TaskNoteTag(tag1.id(), tag1.name(), tag1.userId(), 1L), - // new TaskNoteTag(tag2.id(), tag2.name(), tag2.userId(), 1L), - // new TaskNoteTag(tag3.id(), tag3.name(), tag3.userId(), 1L) - // ); - // when(tagRepository.findAllByUserIdAndTaskIdInList(user.getId(), List.of(1L))) - // .thenReturn(taskTags); + when(tagRepository.findAllTagNamesByUserId(user.getId())) + .thenReturn(List.of("tag1", "tag2", "atag", "dev")); + when(tagRepository.userHasUntaggedItems(user.getId())).thenReturn(false); List homeTags = homeService.getTopTasksTag(); Assertions.assertNotNull(homeTags); - Assertions.assertEquals(8, homeTags.size()); - Assertions.assertEquals(List.of("atag", "dev", "tag1", "tag2", "tag3", "tag4", "tag5", "tag6"), - homeTags); + Assertions.assertEquals(List.of("atag", "dev", "tag1", "tag2"), homeTags); } @Test @DisplayName("Get top tasks tag with no tags should return empty list") void getTopTasksTag_noTags_shouldReturnEmptyList() { - mockUser(); - when(taskService.getTasksByFilter("all")).thenReturn(List.of()); - when(noteService.getAllNotes()).thenReturn(List.of()); + User user = mockUser(); + when(tagRepository.findAllTagNamesByUserId(user.getId())).thenReturn(List.of()); + when(tagRepository.userHasUntaggedItems(user.getId())).thenReturn(false); List topTags = homeService.getTopTasksTag(); @@ -98,19 +79,84 @@ void getTopTasksTag_noTags_shouldReturnEmptyList() { @Test @DisplayName("Get top tasks tag with untagged tasks/notes should include 'untagged'") void getTopTasksTag_withUntagged_shouldIncludeUntagged() { - mockUser(); - - TaskResponse task1 = - new TaskResponse(1L, false, "Task 1", false, null, null, null, List.of(), List.of()); - when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1)); - when(noteService.getAllNotes()).thenReturn(List.of()); + User user = mockUser(); + when(tagRepository.findAllTagNamesByUserId(user.getId())).thenReturn(List.of()); + when(tagRepository.userHasUntaggedItems(user.getId())).thenReturn(true); List topTags = homeService.getTopTasksTag(); Assertions.assertNotNull(topTags); - Assertions.assertEquals(1, topTags.size()); - Assertions.assertTrue(topTags.contains("untagged")); Assertions.assertEquals(List.of("untagged"), topTags); } -} + @Test + @DisplayName("Get home items with no filters should fetch the windowed payload") + void getHomeItems_noFilters_shouldFetchWindowedPayload() { + mockUser(); + TaskResponse task = + new TaskResponse(1L, false, "Task 1", false, null, null, null, List.of(), List.of()); + NoteResponse note = + new NoteResponse(1L, "Note 1", "content", null, null, List.of(), false, null, false); + when(taskService.getHomeTasks(null, null, true)).thenReturn(List.of(task)); + when(noteService.getHomeNotes(null, null, true)).thenReturn(List.of(note)); + + HomeItemsResponse response = homeService.getHomeItems(null, null, null); + + Assertions.assertEquals(1, response.tasks().size()); + Assertions.assertEquals(1, response.notes().size()); + } + + @Test + @DisplayName("Get home items with search should run unbounded search") + void getHomeItems_withSearch_shouldRunUnboundedSearch() { + mockUser(); + when(taskService.getHomeTasks("foo", null, false)).thenReturn(List.of()); + when(noteService.getHomeNotes("foo", null, false)).thenReturn(List.of()); + + HomeItemsResponse response = homeService.getHomeItems("foo", null, "all"); + + Assertions.assertTrue(response.tasks().isEmpty()); + Assertions.assertTrue(response.notes().isEmpty()); + Mockito.verify(taskService).getHomeTasks("foo", null, false); + Mockito.verify(noteService).getHomeNotes("foo", null, false); + } + + @Test + @DisplayName("Get home items with type tasks should skip notes") + void getHomeItems_typeTasks_shouldSkipNotes() { + mockUser(); + when(taskService.getHomeTasks(null, null, true)).thenReturn(List.of()); + + HomeItemsResponse response = homeService.getHomeItems(null, null, "tasks"); + + Assertions.assertTrue(response.notes().isEmpty()); + Mockito.verifyNoInteractions(noteService); + } + + @Test + @DisplayName("Get home items with type notes should skip tasks") + void getHomeItems_typeNotes_shouldSkipTasks() { + mockUser(); + when(noteService.getHomeNotes(null, null, true)).thenReturn(List.of()); + + HomeItemsResponse response = homeService.getHomeItems(null, null, "notes"); + + Assertions.assertTrue(response.tasks().isEmpty()); + Mockito.verifyNoInteractions(taskService); + } + + @Test + @DisplayName("Get home items with tag and search should compose params unbounded") + void getHomeItems_tagAndSearch_shouldComposeUnbounded() { + mockUser(); + when(taskService.getHomeTasks("foo", "work", false)).thenReturn(List.of()); + when(noteService.getHomeNotes("foo", "work", false)).thenReturn(List.of()); + + HomeItemsResponse response = homeService.getHomeItems("foo", "work", null); + + Assertions.assertTrue(response.tasks().isEmpty()); + Assertions.assertTrue(response.notes().isEmpty()); + Mockito.verify(taskService).getHomeTasks("foo", "work", false); + Mockito.verify(noteService).getHomeNotes("foo", "work", false); + } +} diff --git a/server/src/test/resources/sql/HomeItemsRepositoryTest.sql b/server/src/test/resources/sql/HomeItemsRepositoryTest.sql new file mode 100644 index 00000000..3f5fe118 --- /dev/null +++ b/server/src/test/resources/sql/HomeItemsRepositoryTest.sql @@ -0,0 +1,150 @@ +-- Create test user +insert into tasknote.users (email, password, admin, created_at, inactivated_at, last_password_change) +select 'home-items@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null, current_timestamp +where not exists (select 1 from tasknote.users where email = 'home-items@domain.com'); + +-- Create tags +insert into tasknote.tags (name, user_id) +select 'home-tag', (select id from tasknote.users where email='home-items@domain.com') +where not exists (select 1 from tasknote.tags where name = 'home-tag' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.tags (name, user_id) +select 'old-tag', (select id from tasknote.users where email='home-items@domain.com') +where not exists (select 1 from tasknote.tags where name = 'old-tag' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +-- Tasks +insert into tasknote.tasks (description, completed, user_id, last_update, high_priority) +select 'Recent task', false, (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp, false +where not exists (select 1 from tasknote.tasks where description = 'Recent task' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.tasks (description, completed, user_id, last_update, high_priority) +select 'Old task', false, (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp - interval '3 days', false +where not exists (select 1 from tasknote.tasks where description = 'Old task' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.tasks (description, completed, user_id, last_update, high_priority) +select 'Old HP incomplete task', false, + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp - interval '3 days', true +where not exists (select 1 from tasknote.tasks where description = 'Old HP incomplete task' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.tasks (description, completed, user_id, last_update, high_priority) +select 'Old HP completed task', true, + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp - interval '3 days', true +where not exists (select 1 from tasknote.tasks where description = 'Old HP completed task' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.tasks (description, completed, user_id, last_update, high_priority) +select 'Boundary recent completed task', true, + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp - interval '23 hours', false +where not exists (select 1 from tasknote.tasks where description = 'Boundary recent completed task' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.tasks (description, completed, user_id, last_update, high_priority) +select 'Untagged old task', false, + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp - interval '3 days', false +where not exists (select 1 from tasknote.tasks where description = 'Untagged old task' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +-- Task tags +insert into tasknote.task_tags (task_id, tag_id) +select t.id, tg.id +from tasknote.tasks t +join tasknote.tags tg on tg.name = 'home-tag' and tg.user_id = t.user_id +where t.description = 'Recent task' + and t.user_id = (select id from tasknote.users where email='home-items@domain.com') + and not exists (select 1 from tasknote.task_tags where task_id = t.id and tag_id = tg.id); + +insert into tasknote.task_tags (task_id, tag_id) +select t.id, tg.id +from tasknote.tasks t +join tasknote.tags tg on tg.name = 'old-tag' and tg.user_id = t.user_id +where t.description = 'Old task' + and t.user_id = (select id from tasknote.users where email='home-items@domain.com') + and not exists (select 1 from tasknote.task_tags where task_id = t.id and tag_id = tg.id); + +-- Task urls +insert into tasknote.task_url (task_id, url) +select t.id, 'http://searchable-task-url.example.com' +from tasknote.tasks t +where t.description = 'Old task' + and t.user_id = (select id from tasknote.users where email='home-items@domain.com') + and not exists (select 1 from tasknote.task_url where task_id = t.id + and url = 'http://searchable-task-url.example.com'); + +-- Notes +insert into tasknote.notes (title, description, user_id, last_update, shared, archived) +select 'Recent note', 'recent content', + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp, false, false +where not exists (select 1 from tasknote.notes where title = 'Recent note' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.notes (title, description, user_id, last_update, shared, archived) +select 'Old note', 'old content', + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp - interval '3 days', false, false +where not exists (select 1 from tasknote.notes where title = 'Old note' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.notes (title, description, user_id, last_update, shared, archived) +select 'Old archived note', 'old archived content', + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp - interval '3 days', false, true +where not exists (select 1 from tasknote.notes where title = 'Old archived note' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.notes (title, description, user_id, last_update, shared, archived) +select 'Just archived note', 'just archived content', + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp, false, true +where not exists (select 1 from tasknote.notes where title = 'Just archived note' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.notes (title, description, user_id, last_update, shared, archived) +select 'Old note with url', 'note with url content', + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp - interval '3 days', false, false +where not exists (select 1 from tasknote.notes where title = 'Old note with url' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.notes (title, description, user_id, last_update, shared, archived) +select 'Untagged old note', 'untagged note content', + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp - interval '3 days', false, false +where not exists (select 1 from tasknote.notes where title = 'Untagged old note' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +insert into tasknote.notes (title, description, user_id, last_update, shared, archived) +select 'Tagged old note', 'tagged note content', + (select id from tasknote.users where email='home-items@domain.com'), + current_timestamp - interval '3 days', false, false +where not exists (select 1 from tasknote.notes where title = 'Tagged old note' + and user_id = (select id from tasknote.users where email='home-items@domain.com')); + +-- Note tags +insert into tasknote.note_tags (note_id, tag_id) +select n.id, tg.id +from tasknote.notes n +join tasknote.tags tg on tg.name = 'home-tag' and tg.user_id = n.user_id +where n.title = 'Tagged old note' + and n.user_id = (select id from tasknote.users where email='home-items@domain.com') + and not exists (select 1 from tasknote.note_tags where note_id = n.id and tag_id = tg.id); + +-- Note urls +insert into tasknote.note_urls (note_id, url) +select n.id, 'http://searchable-note-url.example.com' +from tasknote.notes n +where n.title = 'Old note with url' + and n.user_id = (select id from tasknote.users where email='home-items@domain.com') + and not exists (select 1 from tasknote.note_urls where note_id = n.id + and url = 'http://searchable-note-url.example.com');