From 9109d5785107093accef9e5d855234c53709868e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 19 May 2026 09:22:34 +0300 Subject: [PATCH 1/2] WIP: start visit progress polish 309 (checkpoint) From 67665581249600b11cb3a39e0b889a712bc7fe43 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 19 May 2026 09:30:54 +0300 Subject: [PATCH 2/2] fix: polish trip editor visit progress modal --- .../src/components/VisitProgressSurface.vue | 87 ++++++-- ClientApps/trip-editor/src/visitProgress.css | 207 ++++++++++++++++-- .../tripEditorVisitProgress.spec.ts | 51 +++++ 3 files changed, 306 insertions(+), 39 deletions(-) diff --git a/ClientApps/trip-editor/src/components/VisitProgressSurface.vue b/ClientApps/trip-editor/src/components/VisitProgressSurface.vue index d77f99c8..642ef400 100644 --- a/ClientApps/trip-editor/src/components/VisitProgressSurface.vue +++ b/ClientApps/trip-editor/src/components/VisitProgressSurface.vue @@ -17,6 +17,9 @@ type VisitPlaceRow = { type VisitRegionGroup = { regionId: Guid; regionName: string; + visitedCount: number; + totalCount: number; + percentVisited: number; rows: VisitPlaceRow[]; }; @@ -42,14 +45,21 @@ const regionGroups = computed(() => { for (const regionId of props.state.regionOrder) { const placeIds = props.state.placeOrderByRegionId[regionId] ?? []; - const rows = placeIds + const allRows = placeIds .map(placeId => visitPlaceRow(regionId, placeId, orderedHistory)) - .filter(row => row && filterMatches(row.summary)) as VisitPlaceRow[]; + .filter(row => row) as VisitPlaceRow[]; + const rows = allRows.filter(row => filterMatches(row.summary)); if (rows.length > 0) { + const visitedCount = allRows.filter(row => row.summary.isVisited).length; + const totalCount = allRows.length; + groups.push({ regionId, regionName: regionName(regionId), + visitedCount, + totalCount, + percentVisited: percentVisited(visitedCount, totalCount), rows }); } @@ -120,6 +130,10 @@ function regionName(regionId: Guid): string { return props.state.regionsById[regionId]?.name || 'Unknown region'; } +function percentVisited(visitedCount: number, totalCount: number): number { + return totalCount > 0 ? Math.round((visitedCount / totalCount) * 100) : 0; +} + function historyRegionName(row: EditorVisitHistoryRow, fallbackRegionId: Guid): string { return props.state.regionsById[row.regionId]?.name || props.state.regionsById[fallbackRegionId]?.name || 'Unknown region'; } @@ -198,30 +212,43 @@ async function manageVisit(event: MouseEvent, visitId: Guid): Promise {
-
- Visit progress - {{ state.visitProgress.percentVisited }}% +
+
+ Visit progress + Your overall trip progress +
+ {{ state.visitProgress.percentVisited }}%
-
Filter places -
@@ -229,14 +256,38 @@ async function manageVisit(event: MouseEvent, visitId: Guid): Promise {
-

{{ group.regionName }}

+
+

{{ group.regionName }}

+ {{ group.visitedCount }} / {{ group.totalCount }} visited +
+
+ +
-
-

{{ row.placeName }}

- {{ row.summary.isVisited ? 'Visited' : 'Not visited' }} +
+ + + +
+

{{ row.placeName }}

+ {{ row.summary.isVisited ? 'Visited' : 'Not visited' }} +
- {{ row.summary.visitCount }} visit{{ row.summary.visitCount === 1 ? '' : 's' }} + {{ row.summary.visitCount }} visit{{ row.summary.visitCount === 1 ? '' : 's' }}
@@ -252,7 +303,7 @@ async function manageVisit(event: MouseEvent, visitId: Guid): Promise {

No visit history rows available for this place.

-
+
{{ row.placeName }} {{ historyRegionName(history, row.regionId) }}
diff --git a/ClientApps/trip-editor/src/visitProgress.css b/ClientApps/trip-editor/src/visitProgress.css index 32bc611f..d172678c 100644 --- a/ClientApps/trip-editor/src/visitProgress.css +++ b/ClientApps/trip-editor/src/visitProgress.css @@ -11,8 +11,7 @@ .trip-editor-visit-progress__summary, .trip-editor-visit-progress__filters, .trip-editor-visit-region, -.trip-editor-visit-place-row, -.trip-editor-visit-history-row { +.trip-editor-visit-place-row { border: 1px solid var(--trip-editor-border); border-radius: 6px; } @@ -24,36 +23,136 @@ padding: 0.75rem; } +.trip-editor-visit-progress__summary { + background: color-mix(in srgb, var(--trip-editor-accent) 8%, var(--trip-editor-card)); + display: grid; + gap: 0.65rem; +} + +.trip-editor-visit-progress__summary-heading { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; + min-width: 0; +} + +.trip-editor-visit-progress__summary-heading > div:first-child { + display: grid; + gap: 0.1rem; + min-width: 0; +} + +.trip-editor-visit-progress__summary-heading span, +.trip-editor-visit-progress__filters legend, +.trip-editor-visit-place-row dt, +.trip-editor-visit-history-row dt { + color: var(--trip-editor-muted); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + +.trip-editor-visit-progress__summary-heading strong { + overflow-wrap: anywhere; +} + +.trip-editor-visit-progress__percent, +.trip-editor-visit-count-pill { + align-items: center; + border-radius: 999px; + display: inline-flex; + font-weight: 700; + justify-content: center; + white-space: nowrap; +} + +.trip-editor-visit-progress__percent { + background: var(--bs-primary-bg-subtle); + border: 1px solid var(--bs-primary-border-subtle); + color: var(--bs-primary-text-emphasis); + font-size: 1rem; + min-width: 3.4rem; + padding: 0.3rem 0.65rem; +} + +.trip-editor-visit-progress__bar, +.trip-editor-visit-region__bar { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--trip-editor-border) 70%, transparent); + margin: 0; +} + +.trip-editor-visit-progress__bar { + height: 0.75rem; +} + +.trip-editor-visit-region__bar { + height: 0.45rem; +} + .trip-editor-visit-progress__summary p { color: var(--trip-editor-muted); - margin: 0.35rem 0 0; + margin: 0; +} + +.trip-editor-visit-progress__summary-count { + align-items: center; + display: inline-flex; + gap: 0.4rem; } .trip-editor-visit-progress__filters { align-items: center; display: flex; flex-wrap: wrap; - gap: 0.75rem; + gap: 0.5rem; margin: 0; } .trip-editor-visit-progress__filters legend { - color: var(--trip-editor-muted); float: none; - font-size: 0.8rem; - font-weight: 700; margin: 0; - text-transform: uppercase; width: auto; } -.trip-editor-visit-progress__filters label { +.trip-editor-visit-filter-option { align-items: center; + cursor: pointer; display: inline-flex; - gap: 0.35rem; margin: 0; } +.trip-editor-visit-filter-option input { + height: 1px; + margin: 0; + opacity: 0; + position: absolute; + width: 1px; +} + +.trip-editor-visit-filter-option > span { + align-items: center; + background: var(--trip-editor-field-bg); + border: 1px solid var(--trip-editor-field-border); + border-radius: 999px; + color: var(--trip-editor-text); + display: inline-flex; + gap: 0.35rem; + min-height: 2rem; + padding: 0.35rem 0.7rem; +} + +.trip-editor-visit-filter-option input:checked + span { + background: color-mix(in srgb, var(--trip-editor-accent) 18%, var(--trip-editor-card-strong)); + border-color: color-mix(in srgb, var(--trip-editor-accent) 62%, var(--trip-editor-field-border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--trip-editor-accent) 25%, transparent); +} + +.trip-editor-visit-filter-option input:focus-visible + span { + outline: 2px solid #93c5fd; + outline-offset: 2px; +} + .trip-editor-visit-region-list { display: grid; gap: 0.9rem; @@ -64,6 +163,14 @@ gap: 0.65rem; } +.trip-editor-visit-region__header { + align-items: center; + display: flex; + gap: 0.75rem; + justify-content: space-between; + min-width: 0; +} + .trip-editor-visit-region h3, .trip-editor-visit-place-row h4 { margin: 0; @@ -73,6 +180,12 @@ font-size: 1rem; } +.trip-editor-visit-region__header span { + color: var(--trip-editor-muted); + flex: 0 0 auto; + font-size: 0.85rem; +} + .trip-editor-visit-place-row { background: var(--trip-editor-card-strong); display: grid; @@ -90,11 +203,58 @@ min-width: 0; } +.trip-editor-visit-place-row__title { + align-items: flex-start; + display: flex; + gap: 0.55rem; + min-width: 0; +} + +.trip-editor-visit-place-row__title > div, +.trip-editor-visit-history-row__title { + display: grid; + gap: 0.1rem; + min-width: 0; +} + .trip-editor-visit-place-row header small, .trip-editor-visit-history-row span { color: var(--trip-editor-muted); } +.trip-editor-visit-status { + align-items: center; + border-radius: 999px; + display: inline-flex; + flex: 0 0 auto; + font-size: 0.75rem; + font-weight: 800; + height: 1.15rem; + justify-content: center; + line-height: 1; + margin-top: 0.1rem; + width: 1.15rem; +} + +.trip-editor-visit-status--visited { + background: var(--bs-success-bg-subtle); + border: 1px solid var(--bs-success-border-subtle); + color: var(--bs-success-text-emphasis); +} + +.trip-editor-visit-status--pending { + background: var(--trip-editor-surface-muted); + border: 1px solid var(--trip-editor-field-border); +} + +.trip-editor-visit-count-pill { + background: var(--bs-success-bg-subtle); + border: 1px solid var(--bs-success-border-subtle); + color: var(--bs-success-text-emphasis); + font-size: 0.8rem; + padding: 0.2rem 0.55rem; +} + .trip-editor-visit-place-row__summary, .trip-editor-visit-history-row dl { display: grid; @@ -110,10 +270,7 @@ .trip-editor-visit-place-row dt, .trip-editor-visit-history-row dt { - color: var(--trip-editor-muted); - font-size: 0.75rem; - font-weight: 700; - text-transform: uppercase; + margin-bottom: 0.05rem; } .trip-editor-visit-place-row dd, @@ -128,16 +285,10 @@ } .trip-editor-visit-history-row { - background: var(--trip-editor-surface); + border-top: 1px solid var(--trip-editor-border); padding: 0.6rem; } -.trip-editor-visit-history-row > div:first-child { - display: grid; - gap: 0.15rem; - min-width: 0; -} - .trip-editor-visit-history-row dl { flex: 1 1 auto; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -159,3 +310,17 @@ min-width: 0; } } + +@media (max-width: 520px) { + .trip-editor-visit-progress__summary-heading, + .trip-editor-visit-region__header, + .trip-editor-visit-place-row header { + align-items: stretch; + flex-direction: column; + } + + .trip-editor-visit-progress__percent, + .trip-editor-visit-count-pill { + width: fit-content; + } +} diff --git a/tests/e2e/trip-editor/tripEditorVisitProgress.spec.ts b/tests/e2e/trip-editor/tripEditorVisitProgress.spec.ts index 20c0a6ab..c469bf40 100644 --- a/tests/e2e/trip-editor/tripEditorVisitProgress.spec.ts +++ b/tests/e2e/trip-editor/tripEditorVisitProgress.spec.ts @@ -29,11 +29,21 @@ test.describe.serial('Trip Editor visit progress and history', () => { await openVisits(page); const dialog = visitDialog(page); await expect(dialog).toContainText('2 / 3 places visited'); + await expect(dialog.getByText('67%')).toBeVisible(); + await expect(dialog.getByRole('progressbar', { name: 'Overall visit progress' })).toHaveAttribute('aria-valuenow', '67'); + await expect(dialog.locator('.trip-editor-visit-filter-option')).toHaveCount(3); await expect(visitPlaceRow(page, visitedPlaceId)).toContainText('PW visited place'); await expect(visitPlaceRow(page, notVisitedPlaceId)).toContainText('PW not visited place'); await expect(visitPlaceRow(page, missingHistoryPlaceId)).toContainText('PW missing history place'); + await expect(dialog.getByRole('region', { name: 'PW visit region one' })).toContainText('2 / 2 visited'); + await expect(dialog.getByRole('progressbar', { name: 'PW visit region one visit progress' })).toHaveAttribute('aria-valuenow', '100'); + await expect(dialog.getByRole('region', { name: 'PW visit region two' })).toContainText('0 / 1 visited'); + await expect(dialog.getByRole('progressbar', { name: 'PW visit region two visit progress' })).toHaveAttribute('aria-valuenow', '0'); + await expect(visitPlaceRow(page, visitedPlaceId).getByRole('img', { name: 'Visited' })).toBeVisible(); + await expect(visitPlaceRow(page, notVisitedPlaceId).getByRole('img', { name: 'Not visited' })).toBeVisible(); await dialog.getByRole('radio', { name: 'Visited', exact: true }).check(); + await expect(dialog.getByRole('radio', { name: 'Visited', exact: true })).toBeChecked(); await expect(visitPlaceRow(page, visitedPlaceId)).toBeVisible(); await expect(visitPlaceRow(page, missingHistoryPlaceId)).toBeVisible(); await expect(visitPlaceRow(page, notVisitedPlaceId)).toHaveCount(0); @@ -56,6 +66,7 @@ test.describe.serial('Trip Editor visit progress and history', () => { const row = visitPlaceRow(page, visitedPlaceId); await expect(row).toContainText('Visited'); await expect(row).toContainText('3 visits'); + await expect(row.locator('.trip-editor-visit-count-pill')).toHaveText('3 visits'); await expect(row).toContainText('First visit'); await expect(row).toContainText('2026-01-01 08:00 UTC'); await expect(row).toContainText('Last visit'); @@ -68,6 +79,7 @@ test.describe.serial('Trip Editor visit progress and history', () => { await expect(row.locator(`[data-visit-id="${newerVisitId}"]`)).toContainText('2026-01-03 08:00 UTC'); await expect(row.locator(`[data-visit-id="${newerVisitId}"]`)).toContainText('Open'); await expect(row.locator(`[data-visit-id="${newerVisitId}"]`)).toContainText('Duration unavailable'); + await expect(row.locator(`[data-visit-id="${newerVisitId}"]`).getByRole('link', { name: 'Manage visit' })).toHaveAttribute('href', `/User/Visit/Edit/${newerVisitId}`); await expect(row.locator(`[data-visit-id="${olderVisitId}"]`)).toContainText('45 min'); }); @@ -153,6 +165,21 @@ test.describe.serial('Trip Editor visit progress and history', () => { await openVisits(page); await expect(visitPlaceRow(page, missingHistoryPlaceId)).toContainText('No visit history rows available for this place.'); }); + + test('keeps the visit progress modal contained at desktop and narrow widths', async ({ page }) => { + for (const viewport of [ + { width: 1280, height: 900 }, + { width: 390, height: 900 } + ]) { + await page.setViewportSize(viewport); + await signIn(page); + await loadWorkspaceWithVisitFixture(page, prepareMixedVisitState); + await openVisits(page); + await expectDialogFitsViewport(page); + await expectNoPageOverflow(page); + await visitDialog(page).getByRole('button', { name: 'Close' }).click(); + } + }); }); async function loadWorkspaceWithVisitFixture(page: Page, prepare: (state: MutableEditorState) => void): Promise { @@ -298,6 +325,30 @@ function visitPlaceRow(page: Page, placeId: string) { return page.locator(`[data-visit-place-id="${placeId}"]`); } +async function expectNoPageOverflow(page: Page): Promise { + const overflow = await page.evaluate(() => { + const viewportWidth = document.documentElement.clientWidth; + const documentOverflow = document.documentElement.scrollWidth - viewportWidth; + const bodyOverflow = document.body ? document.body.scrollWidth - viewportWidth : 0; + + return { documentOverflow, bodyOverflow }; + }); + + expect(overflow.documentOverflow, 'Visit progress modal should not introduce horizontal document overflow.').toBeLessThanOrEqual(2); + expect(overflow.bodyOverflow, 'Visit progress modal should not introduce horizontal body overflow.').toBeLessThanOrEqual(2); +} + +async function expectDialogFitsViewport(page: Page): Promise { + const dialog = page.locator('.trip-editor-expanded__dialog:visible').first(); + const [box, viewport] = await Promise.all([dialog.boundingBox(), page.viewportSize()]); + expect(box, 'Visit progress dialog should have a rendered box.').not.toBeNull(); + expect(viewport, 'Viewport should be available for dialog fit check.').not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(-1); + expect(box!.y).toBeGreaterThanOrEqual(-1); + expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1); + expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1); +} + async function clickMap(page: Page, position: { xRatio: number; yRatio: number }): Promise { const map = page.getByLabel('Read-only trip map'); await map.evaluate((element, point) => {