feat(client): show Dawarich stays, routes and marks where they belong

The trip planner and the journal get a "From Dawarich" list: the stays
recorded for those dates, each one a row that can be dismissed or turned
into a place or an entry after a review step that shows time, name, note
and day. A place that came from a recording carries the Dawarich mark
next to its name so its origin stays visible afterwards.

The recorded route lies over the trip and journal maps as an overlay,
coloured per day and cut along the reader's own timezone rather than
UTC. It is fetched for the window on screen and never stored.

The Atlas gets its own panel beside the map for the countries and cities
the recordings prove, and the wish list is ticked off there, on the day
of the visit, undoably. The phone has its own take on all of it, in the
mobile shell's colours and with native sheets.
This commit is contained in:
Maurice
2026-09-12 23:35:43 +02:00
parent 5678eed55c
commit 5b4a7e82ff
108 changed files with 8945 additions and 295 deletions
@@ -180,12 +180,19 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
};
}
function setup(over: Record<string, unknown> = {}) {
function setup(over: Record<string, unknown> = {}, entries?: string[]) {
mocks.detail = buildHook(over);
const view = render(<MJourneyDetail />);
const view = render(<MJourneyDetail />, entries ? { initialEntries: entries } : undefined);
return { ...view, hook: mocks.detail };
}
/**
* Opening the upload the way the shell does since the Gallery took over the
* dock's FAB: the button asks through `?create=photo`, the screen answers. The
* header no longer carries an upload button of its own — one screen, one upload.
*/
const UPLOAD_URL = ['/journey/1?create=photo'];
const journeyStoreInitial = useJourneyStore.getState();
let uploadGalleryPhotos: ReturnType<typeof vi.fn>;
let createEntry: ReturnType<typeof vi.fn>;
@@ -355,9 +362,8 @@ describe('MJourneyDetail', () => {
],
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
setup({ view: 'gallery' });
setup({ view: 'gallery' }, UPLOAD_URL);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
expect(await screen.findByText('mobileJourney.uploadFromDevice')).toBeInTheDocument();
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
expect(await screen.findByTestId('provider-picker')).toBeInTheDocument();
@@ -370,10 +376,9 @@ describe('MJourneyDetail', () => {
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
const toGallery = vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockResolvedValue({ added: 3 });
const toEntry = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 });
const { hook } = setup({ view: 'gallery' });
const { hook } = setup({ view: 'gallery' }, UPLOAD_URL);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
await screen.findByTestId('provider-picker');
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise<void>;
@@ -392,10 +397,9 @@ describe('MJourneyDetail', () => {
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockRejectedValue(new Error('boom'));
setup({ view: 'gallery' });
setup({ view: 'gallery' }, UPLOAD_URL);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
await screen.findByTestId('provider-picker');
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise<void>;
@@ -509,11 +513,11 @@ describe('MJourneyDetail', () => {
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
const { container } = setup({ view: 'gallery' });
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
const click = vi.spyOn(input, 'click').mockImplementation(() => {});
// The upload opens on mount now (the dock asks through the URL), so the spy
// goes on the prototype rather than on an input that does not exist yet.
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {});
setup({ view: 'gallery' }, UPLOAD_URL);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
fireEvent.click(await screen.findByText('mobileJourney.uploadFromDevice'));
expect(click).toHaveBeenCalled();
await waitFor(() => expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument());
@@ -524,12 +528,10 @@ describe('MJourneyDetail', () => {
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: false });
const { container } = setup({ view: 'gallery' });
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
const click = vi.spyOn(input, 'click').mockImplementation(() => {});
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {});
setup({ view: 'gallery' }, UPLOAD_URL);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
expect(click).toHaveBeenCalled();
await waitFor(() => expect(click).toHaveBeenCalled());
expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument();
});
@@ -545,7 +547,7 @@ describe('MJourneyDetail', () => {
editingEntry: buildEntry({ id: 4 }),
showSettings: true,
showInvite: true,
});
}, UPLOAD_URL);
(mocks.captured.entrySheet.onClose as () => void)();
expect(hook.setEditingEntry).toHaveBeenCalledWith(null);
@@ -554,8 +556,7 @@ describe('MJourneyDetail', () => {
(mocks.captured.invite.onClose as () => void)();
expect(hook.setShowInvite).toHaveBeenCalledWith(false);
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
await screen.findByTestId('provider-picker');
expect([...(mocks.captured.picker.existingAssetIds as Set<string>)]).toEqual(['asset-1']);