feat(collections): "want to revisit" status for saved places (#2567)

A fourth status after visited, for a place already seen that the user wants
to go back to. It joins the one-tap cycle, the filters, the pickers, the
assistant cards and the MCP status tool, and counts as visited wherever
visits are counted (isVisitedCollectionStatus), so "visited everywhere"
keeps it. The status pickers go two by two where four would not fit.
This commit is contained in:
Maurice
2026-11-13 09:28:17 +01:00
parent 73ac4b29ad
commit 72e8b1f8d9
56 changed files with 178 additions and 51 deletions
@@ -1,5 +1,5 @@
/**
* FE-AI-COLL-010 to FE-AI-COLL-017
* FE-AI-COLL-010 to FE-AI-COLL-017, FE-AI-COLL-040
*
* The answer cards of the collections as the chat draws them: a list's head
* with its size, people and places per status, its saved places as rows
@@ -57,6 +57,14 @@ describe('collection answer cards', () => {
expect(view.onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/collections/12' });
});
it('FE-AI-COLL-040: the places to revisit have a pill of their own in the head, in the visited tone (#2567)', () => {
draw([placesCard({ list: { ...placesCard().list, counts: { idea: 3, want: 5, visited: 4, revisit: 7 } } })]);
const card = screen.getByRole('region', { name: 'Saved places in Paris food' });
const pill = within(card).getByText('Want to revisit').parentElement as HTMLElement;
expect(within(pill).getByText('7')).toBeInTheDocument();
expect(within(card).getByText('Visited').parentElement?.className).toBe(pill.className);
});
it('FE-AI-COLL-011: a saved place shows its status, two labels and the rest counted, its rating and the trip having it', () => {
draw([placesCard({ places: [savedPlace({ inTripPlaceId: 6 })] })]);
expect(screen.getByText('Septime')).toBeInTheDocument();
@@ -1,10 +1,11 @@
import type {
CollectionCardDelta,
CollectionCardLabel,
CollectionCardList,
CollectionCardPlace,
CollectionListsCard,
CollectionPlacesCard,
import {
type CollectionCardDelta,
type CollectionCardLabel,
type CollectionCardList,
type CollectionCardPlace,
type CollectionListsCard,
type CollectionPlacesCard,
isVisitedCollectionStatus,
} from '@trek/shared';
import { Bookmark, Info, Tag } from 'lucide-react';
import type { ReactNode } from 'react';
@@ -110,7 +111,7 @@ function ListChip({ list }: { list: CollectionCardList }) {
function statusChange(status: NonNullable<CollectionCardDelta['status']>, t: T): ReactNode {
const to = STATUS_META[status.to];
const before = status.from ? t(STATUS_META[status.from].labelKey) : null;
const tone = status.to === 'visited' ? 'success' : 'neutral';
const tone = isVisitedCollectionStatus(status.to) ? 'success' : 'neutral';
return <FactChange key="status" icon={to.icon} before={before} after={t(to.labelKey)} tone={tone} />;
}
@@ -1,10 +1,16 @@
import type { CollectionCardLabel, CollectionCardList, CollectionCardPlace, CollectionStatus } from '@trek/shared';
import {
type CollectionCardLabel,
type CollectionCardList,
type CollectionCardPlace,
type CollectionStatus,
isVisitedCollectionStatus,
} from '@trek/shared';
import { Star } from 'lucide-react';
import type { CSSProperties } from 'react';
import { useTranslation } from '../../../../i18n';
import { safeHexColor } from '../../../../utils/safeColor';
import { STATUS_META } from '../../../Collections/collectionStatus';
import { STATUS_META, STATUS_ORDER } from '../../../Collections/collectionStatus';
import { SoftPill, tintOf, WhiteBadge } from '../../../Planner/planParts';
import PlaceAvatar from '../../../shared/PlaceAvatar';
import { Tooltip } from '../../../shared/Tooltip';
@@ -93,7 +99,7 @@ export function StatusPill({ status, count }: { status: CollectionStatus; count?
<Icon size={10} strokeWidth={2.4} aria-hidden="true" className="flex-none" style={{ color: meta.color }} />
);
return (
<SoftPill tone={status === 'visited' ? 'success' : 'neutral'} icon={icon}>
<SoftPill tone={isVisitedCollectionStatus(status) ? 'success' : 'neutral'} icon={icon}>
{count !== undefined && <span className="tabular-nums">{count}</span>}
<span>{t(meta.labelKey)}</span>
</SoftPill>
@@ -140,7 +146,7 @@ export function ListHead({ list, first = true }: { list: CollectionCardList; fir
</div>
{counts && (
<CardFacts>
{(['idea', 'want', 'visited'] as const).map((status) => (
{STATUS_ORDER.map((status) => (
<StatusPill key={status} status={status} count={counts[status]} />
))}
</CardFacts>
@@ -1,4 +1,4 @@
// FE-COMP-STATUSBADGE-001 to FE-COMP-STATUSBADGE-013
// FE-COMP-STATUSBADGE-001 to FE-COMP-STATUSBADGE-014
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionStatus } from '@trek/shared';
@@ -49,7 +49,7 @@ describe('StatusBadge', () => {
expect(screen.queryByText('Idea')).not.toBeInTheDocument();
});
// ── One-tap cycle: idea → want → visited → idea ──────────────────────────────
// ── One-tap cycle: idea → want → visited → revisit → idea ────────────────────
it('FE-COMP-STATUSBADGE-005: exposes a button role when onChange is supplied', () => {
render(<Badge status="idea" onChange={vi.fn()} />);
@@ -73,11 +73,19 @@ describe('StatusBadge', () => {
expect(onChange).toHaveBeenCalledWith('visited');
});
it('FE-COMP-STATUSBADGE-008: clicking a visited badge wraps around to "idea"', async () => {
it('FE-COMP-STATUSBADGE-008: clicking a visited badge moves on to "revisit"', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Badge status="visited" onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Visited' }));
expect(onChange).toHaveBeenCalledWith('revisit');
});
it('FE-COMP-STATUSBADGE-014: clicking a revisit badge wraps around to "idea" (#2567)', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Badge status="revisit" onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Want to revisit' }));
expect(onChange).toHaveBeenCalledWith('idea');
});
@@ -5,7 +5,7 @@ import { STATUS_META, nextStatus } from './collectionStatus'
interface StatusBadgeProps {
status: CollectionStatus
/** One-tap cycle: idea → want → visited → idea. Omit for a read-only badge. */
/** One-tap cycle: idea → want → visited → revisit → idea. Omit for a read-only badge. */
onChange?: (next: CollectionStatus) => void
showLabel?: boolean
size?: number
@@ -15,7 +15,7 @@ interface StatusBadgeProps {
}
/**
* Coloured per-place status pill (idea / want-to-go / visited). When `onChange`
* Coloured per-place status pill (idea / want-to-go / visited / revisit). When `onChange`
* is supplied a single tap cycles the status optimistically; otherwise it is a
* static badge. Two skins: the default surface pill (list / inspector) and the
* `onCover` dark-glass pill that stays legible on top of a photo cover. Styled
@@ -1,5 +1,5 @@
import { COLLECTION_STATUSES, type CollectionStatus } from '@trek/shared';
import { Bookmark, CheckCircle2, Circle, type LucideIcon } from 'lucide-react';
import { Bookmark, CheckCircle2, Circle, RotateCcw, type LucideIcon } from 'lucide-react';
/**
* How a saved place's status looks wherever it shows: its icon, its name and
@@ -26,12 +26,13 @@ export const STATUS_META: Record<CollectionStatus, StatusMeta> = {
color: 'var(--success)',
coverColor: '#6ee7b7',
},
revisit: { icon: RotateCcw, labelKey: 'collections.status.revisit', color: 'var(--warning)', coverColor: '#fcd34d' },
};
/** Stable order for the filter chips + the one-tap cycle. */
export const STATUS_ORDER: CollectionStatus[] = [...COLLECTION_STATUSES];
/** idea → want → visited → idea */
/** idea → want → visited → revisit → idea */
export function nextStatus(status: CollectionStatus): CollectionStatus {
const i = STATUS_ORDER.indexOf(status);
return STATUS_ORDER[(i + 1) % STATUS_ORDER.length];
@@ -108,17 +108,19 @@ describe('useSaveToCollection', () => {
expect(result.current.unvisited).toEqual([]);
});
it('FE-COMP-SAVETOCOLHOOK-004: unvisited leaves out visited and read-only lists', async () => {
it('FE-COMP-SAVETOCOLHOOK-004: unvisited leaves out visited, to-revisit and read-only lists', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
saved: true,
lists: [
{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'visited', can_edit: true },
{ collection_id: 2, name: 'Wishlist', place_id: 901, status: 'idea', can_edit: false },
{ collection_id: 3, name: 'Team', place_id: 902, status: 'want', can_edit: true },
// #2567: a place to revisit was visited already, "visited everywhere" keeps the wish.
{ collection_id: 4, name: 'Again', place_id: 903, status: 'revisit', can_edit: true },
],
});
const { result } = await loaded();
await waitFor(() => expect(result.current.savedByCollection.size).toBe(3));
await waitFor(() => expect(result.current.savedByCollection.size).toBe(4));
expect(result.current.unvisited.map((l) => l.place_id)).toEqual([902]);
});
@@ -1,4 +1,9 @@
import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared';
import {
type Collection,
type CollectionMembership,
type CollectionStatus,
isVisitedCollectionStatus,
} from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
@@ -110,8 +115,8 @@ export function useSaveToCollection() {
const savedByCollection = new Map<number, MembershipEntry>();
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l);
/** Lists holding this place that the viewer may edit and that are not visited yet. */
const unvisited = (membership?.lists ?? []).filter((l) => l.can_edit && l.status !== 'visited');
/** Lists holding this place that the viewer may edit and that are not visited yet; one to revisit already was. */
const unvisited = (membership?.lists ?? []).filter((l) => l.can_edit && !isVisitedCollectionStatus(l.status));
const handleStatus = async (entry: MembershipEntry, next: CollectionStatus) => {
if (busyId != null) return;
@@ -119,7 +119,8 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
<MCollCategoryPicker categories={categories} value={categoryId} onChange={setCategoryId} t={t} />
<Eyebrow className="mb-[6px] mt-[14px]">{t('mobileCollections.status').toUpperCase()}</Eyebrow>
<div className="flex gap-[6px]">
{/* Two by two on a phone, so every status label keeps its room */}
<div className="grid grid-cols-2 gap-[6px] wide:grid-cols-4">
{STATUS_ORDER.map(s => {
const meta = STATUS_SPEC[s]
const Icon = meta.icon
@@ -127,8 +127,8 @@ export default function MCollPlaceSheet({
</div>
)}
{/* Status cycle */}
<div className="mt-3 flex gap-[6px]">
{/* Status cycle: two by two on a phone, so every label keeps its room */}
<div className="mt-3 grid grid-cols-2 gap-[6px] wide:grid-cols-4">
{STATUS_ORDER.map(s => {
const meta = STATUS_SPEC[s]
const Icon = meta.icon
@@ -1,6 +1,6 @@
import {
BedDouble, Bike, Bookmark, Camera, CheckCircle2, Circle, CircleSlash, Coffee,
Landmark, MapPin, ShoppingBag, TrainFront, Trees, Umbrella, Utensils,
Landmark, MapPin, RotateCcw, ShoppingBag, TrainFront, Trees, Umbrella, Utensils,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { CollectionStatus } from '@trek/shared'
@@ -61,11 +61,12 @@ export interface MStatusMeta {
labelKey: string
}
/** Status canon: Idea → Want to go → Visited. */
/** Status canon: Idea → Want to go → Visited → Want to revisit. */
export const STATUS_SPEC: Record<CollectionStatus, MStatusMeta> = {
idea: { color: '#9A9AA1', icon: Circle, labelKey: 'collections.status.idea' }, // theme-lint-disable
want: { color: '#4A7DDB', icon: Bookmark, labelKey: 'collections.status.want' }, // theme-lint-disable
visited: { color: '#2FA37A', icon: CheckCircle2, labelKey: 'collections.status.visited' }, // theme-lint-disable
revisit: { color: 'var(--m-st-pending)', icon: RotateCcw, labelKey: 'collections.status.revisit' },
}
/** Hex + alpha-suffix tints the design uses for tiles/pills (e.g. #4A7DDB1f). */
@@ -241,11 +241,11 @@ describe('collectionsModel', () => {
cp({ id: 5, name: 'E', status: 'visited' }),
cp({ id: 6, name: 'F', status: 'visited' }),
];
expect(statusCounts(places)).toEqual({ all: 6, idea: 2, want: 1, visited: 3 });
expect(statusCounts(places)).toEqual({ all: 6, idea: 2, want: 1, visited: 3, revisit: 0 });
});
it('FE-COLLECTIONS-MODEL-015: empty input yields all zeros', () => {
expect(statusCounts([])).toEqual({ all: 0, idea: 0, want: 0, visited: 0 });
expect(statusCounts([])).toEqual({ all: 0, idea: 0, want: 0, visited: 0, revisit: 0 });
});
it('FE-COLLECTIONS-MODEL-016: undefined entries are skipped from every count', () => {
@@ -255,7 +255,12 @@ describe('collectionsModel', () => {
cp({ id: 2, name: 'B', status: 'want' }),
];
expect(() => statusCounts(places)).not.toThrow();
expect(statusCounts(places)).toEqual({ all: 2, idea: 1, want: 1, visited: 0 });
expect(statusCounts(places)).toEqual({ all: 2, idea: 1, want: 1, visited: 0, revisit: 0 });
});
it('FE-COLLECTIONS-MODEL-046: a place to revisit counts under its own status (#2567)', () => {
const places = [cp({ id: 1, name: 'A', status: 'revisit' }), cp({ id: 2, name: 'B', status: 'visited' })];
expect(statusCounts(places)).toEqual({ all: 2, idea: 0, want: 0, visited: 1, revisit: 1 });
});
});
@@ -352,14 +357,15 @@ describe('collectionsModel', () => {
// ── status metadata + cycle ─────────────────────────────────────────────────
describe('status metadata', () => {
it('FE-COLLECTIONS-MODEL-040: nextStatus cycles idea → want → visited → idea', () => {
it('FE-COLLECTIONS-MODEL-040: nextStatus cycles idea → want → visited → revisit → idea', () => {
expect(nextStatus('idea')).toBe('want');
expect(nextStatus('want')).toBe('visited');
expect(nextStatus('visited')).toBe('idea');
expect(nextStatus('visited')).toBe('revisit');
expect(nextStatus('revisit')).toBe('idea');
});
it('FE-COLLECTIONS-MODEL-041: STATUS_ORDER holds every status exactly once', () => {
expect(STATUS_ORDER).toEqual(['idea', 'want', 'visited']);
expect(STATUS_ORDER).toEqual(['idea', 'want', 'visited', 'revisit']);
});
it('FE-COLLECTIONS-MODEL-042: every status has a label key, an icon and both colours', () => {
@@ -50,7 +50,7 @@ export function filterPlaces(
/** Count places per status for the filter chips. */
export function statusCounts(places: CollectionPlace[]): Record<StatusFilter, number> {
const counts: Record<StatusFilter, number> = { all: 0, idea: 0, want: 0, visited: 0 }
const counts: Record<StatusFilter, number> = { all: 0, idea: 0, want: 0, visited: 0, revisit: 0 }
for (const p of places) { if (!p) continue; counts.all += 1; counts[p.status] += 1 }
return counts
}
+3 -1
View File
@@ -546,7 +546,7 @@
the link chips below — it is an action on the place, not a piece of it. */
/* status segmented control */
.trek-dash .col-detail-seg { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 4px; border-radius: 12px; background: var(--surface-2); }
.trek-dash .col-detail-seg { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; padding: 4px; border-radius: 12px; background: var(--surface-2); }
.trek-dash .col-detail-seg button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 6px; border-radius: 9px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); transition: background .12s, color .12s, box-shadow .12s; }
.trek-dash .col-detail-seg button:hover { color: var(--ink); }
.trek-dash .col-detail-seg button.on { background: var(--surface); color: var(--ink); box-shadow: var(--sh-xs); }
@@ -595,6 +595,8 @@
@media (max-width: 720px) {
.trek-dash .col-detail { inset-inline: 0; bottom: 0; transform: none; width: 100%; max-height: 84dvh; border-radius: var(--r-xl) var(--r-xl) 0 0; }
/* four statuses side by side leave a label no room at this width: two by two */
.trek-dash .col-detail-seg { grid-template-columns: repeat(2, 1fr); }
/* the read-mode footer (Edit · Copy · Remove) would otherwise clip its last
button — let it wrap, drop the growing spacer, and clear the home indicator */
.trek-dash .col-detail-footer { flex-wrap: wrap; padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); }
+1 -1
View File
@@ -30,7 +30,7 @@ export function savedPlace(over: Partial<CollectionCardPlace> = {}): CollectionC
export function placesCard(over: Partial<CollectionPlacesCard> = {}): CollectionPlacesCard {
return {
kind: 'collectionPlaces',
list: { ...LIST, counts: { idea: 3, want: 5, visited: 4 } },
list: { ...LIST, counts: { idea: 3, want: 5, visited: 4, revisit: 0 } },
places: [savedPlace()],
more: 0,
labels: [
@@ -120,6 +120,9 @@ describe('MCollPlaceSheet', () => {
fireEvent.click(screen.getByRole('button', { name: 'Visited' }))
expect(onSetStatus).toHaveBeenCalledWith('visited')
// #2567: the fourth status sits in the same cycle.
fireEvent.click(screen.getByRole('button', { name: 'Want to revisit' }))
expect(onSetStatus).toHaveBeenCalledWith('revisit')
})
it('FE-MOB-CPLSH-005: a read-only member cannot change the status or edit', () => {
@@ -21,10 +21,12 @@ describe('collectionsMobileModel', () => {
expect(categoryMeta({ name: null, color: '#123456' })).toBeNull()
})
it('FE-MOB-COLL-003: the status canon covers the Idea → Want to go → Visited cycle', () => {
it('FE-MOB-COLL-003: the status canon covers the Idea → Want to go → Visited → Want to revisit cycle', () => {
expect(STATUS_SPEC.idea.labelKey).toBe('collections.status.idea')
expect(STATUS_SPEC.want.labelKey).toBe('collections.status.want')
expect(STATUS_SPEC.visited.labelKey).toBe('collections.status.visited')
expect(STATUS_SPEC.revisit.labelKey).toBe('collections.status.revisit')
expect(STATUS_SPEC.revisit.color).toBe('var(--m-st-pending)')
expect(STATUS_SPEC.idea.color).toBe('#9A9AA1')
expect(STATUS_SPEC.want.color).toBe('#4A7DDB')
expect(STATUS_SPEC.visited.color).toBe('#2FA37A')
@@ -61,7 +61,7 @@ export function statusArg(value: unknown): CollectionStatus | null {
/** The places of a whole list by status. */
export function tally(places: readonly Row[]): NonNullable<CollectionCardList['counts']> {
const counts = { idea: 0, want: 0, visited: 0 };
const counts = { idea: 0, want: 0, visited: 0, revisit: 0 };
for (const place of places) counts[statusOf(place.status)] += 1;
return counts;
}
@@ -346,7 +346,8 @@ export class CollectionsMcp {
@Tool({
name: 'set_collection_place_status',
description: "Set a saved place's status: idea, want, or visited.",
description:
"Set a saved place's status: idea, want, visited, or revisit (been there and wants to go back; it counts as visited).",
inputSchema: { placeId: idSchema, status: z.enum(COLLECTION_STATUSES) },
annotations: TOOL_ANNOTATIONS_WRITE,
when: collectionsAddonOn,
@@ -524,6 +524,15 @@ describe('Tool: update_collection_place / set_collection_place_status / rate_col
) as { place: { status: string } };
expect(status.place.status).toBe('want');
// #2567: "want to revisit" is stored as a status of its own.
const revisit = parseToolResult(
await h.client.callTool({
name: 'set_collection_place_status',
arguments: { placeId: pid, status: 'revisit' },
}),
) as { place: { status: string } };
expect(revisit.place.status).toBe('revisit');
const rated = parseToolResult(
await h.client.callTool({ name: 'rate_collection_place', arguments: { placeId: pid, rating: 5 } }),
) as { place: { rating_avg: number | null; rating_count: number } };
@@ -1,5 +1,5 @@
/**
* ASTCOL-001 to ASTCOL-012: the cards the collections' reads draw beside the
* ASTCOL-001 to ASTCOL-012 and ASTCOL-036: the cards the collections' reads draw beside the
* answer, built from the tool's raw answer through the guarded registry, so
* every card here also passed the card union and the size cap.
*/
@@ -30,10 +30,17 @@ describe('collection answer cards', () => {
color: '#e11d48',
placeCount: 10,
memberCount: 1,
counts: { idea: 4, want: 1, visited: 5 },
counts: { idea: 4, want: 1, visited: 5, revisit: 0 },
});
});
it('ASTCOL-036: a place to revisit is counted under its own status (#2567)', () => {
const value = collectionValue();
value.places[1] = { ...value.places[1], status: 'revisit' };
const shown = places('get_collection', { collectionId: 12 }, value);
expect(shown?.list?.counts).toEqual({ idea: 4, want: 1, visited: 4, revisit: 1 });
});
it('ASTCOL-002: eight places are shown and the rest counted, in the order the list keeps them', () => {
const shown = places('get_collection', { collectionId: 12 }, collectionValue());
expect(shown?.places).toHaveLength(8);
@@ -138,7 +138,7 @@ describe('collection change cards', () => {
color: null,
placeCount: null,
memberCount: 0,
counts: { idea: 0, want: 0, visited: 0 },
counts: { idea: 0, want: 0, visited: 0, revisit: 0 },
},
removed: false,
});
@@ -45,8 +45,15 @@ export const collectionCardListSchema = z.object({
placeCount: cardCountSchema.nullable(),
/** Accepted members besides the owner. */
memberCount: cardCountSchema,
/** Places per status, only where the whole list was read. */
counts: z.object({ idea: cardCountSchema, want: cardCountSchema, visited: cardCountSchema }).nullable(),
/** Places per status, only where the whole list was read. A card saved before 'revisit' existed reads 0 there. */
counts: z
.object({
idea: cardCountSchema,
want: cardCountSchema,
visited: cardCountSchema,
revisit: cardCountSchema.default(0),
})
.nullable(),
});
export type CollectionCardList = z.infer<typeof collectionCardListSchema>;
@@ -5,10 +5,28 @@ import {
collectionPlaceUpdateRequestSchema,
collectionReorderRequestSchema,
collectionSavePlaceRequestSchema,
collectionStatusSchema,
isVisitedCollectionStatus,
} from './collection.schema';
import { describe, expect, it } from 'vitest';
describe('collectionStatusSchema', () => {
// #2567: "want to revisit" is a status of its own, stored as it is.
it('accepts revisit next to the first three', () => {
for (const status of ['idea', 'want', 'visited', 'revisit'] as const) {
expect(collectionStatusSchema.parse(status)).toBe(status);
}
});
it('counts revisit as visited, and nothing before it', () => {
expect(isVisitedCollectionStatus('visited')).toBe(true);
expect(isVisitedCollectionStatus('revisit')).toBe(true);
expect(isVisitedCollectionStatus('want')).toBe(false);
expect(isVisitedCollectionStatus('idea')).toBe(false);
});
});
describe('collectionPlaceUpdateRequestSchema', () => {
// Regression for #1437: an update that omits `status` must NOT inject a default
// of 'idea', otherwise the server overwrites the stored status on every edit-save.
+11 -1
View File
@@ -9,10 +9,20 @@ import { tagSchema } from '../tag/tag.schema';
import { z } from 'zod';
export const COLLECTION_STATUSES = ['idea', 'want', 'visited'] as const;
/**
* A saved place's status, in the order the one-tap cycle walks. 'revisit' (#2567)
* is a place already visited that the user wants to go back to; it sits last so
* the stored values and the order of the first three stay as they were.
*/
export const COLLECTION_STATUSES = ['idea', 'want', 'visited', 'revisit'] as const;
export const collectionStatusSchema = z.enum(COLLECTION_STATUSES).catch('idea').default('idea');
export type CollectionStatus = (typeof COLLECTION_STATUSES)[number];
/** Whether the status means "been there": a place to revisit counts as visited wherever visits are counted. */
export function isVisitedCollectionStatus(status: CollectionStatus): boolean {
return status === 'visited' || status === 'revisit';
}
/** Per-member permission on a shared list. viewer = read + copy-to-trip only;
* editor (default) = add + edit places; admin = full incl. delete. The owner
* is always full and is not a member row. */
+1
View File
@@ -154,6 +154,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'فكرة',
'collections.status.want': 'أريد الذهاب',
'collections.status.visited': 'تمت الزيارة',
'collections.status.revisit': 'أريد العودة',
'collections.status.filterAll': 'الكل',
'collections.status.cycleHint': 'انقر للتغيير',
+1
View File
@@ -106,6 +106,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'İdeya',
'collections.status.want': 'Getmək istəyirəm',
'collections.status.visited': 'Ziyarət edilib',
'collections.status.revisit': 'Yenidən getmək istəyirəm',
'collections.status.filterAll': 'Hamısı',
'collections.status.cycleHint': 'dəyişmək üçün toxunun',
+1
View File
@@ -106,6 +106,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Ideia',
'collections.status.want': 'Quero ir',
'collections.status.visited': 'Visitado',
'collections.status.revisit': 'Quero voltar',
'collections.status.filterAll': 'Todos',
'collections.status.cycleHint': 'toque para alterar',
+1
View File
@@ -102,6 +102,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Idea', // same-as-en
'collections.status.want': 'Hi vull anar',
'collections.status.visited': 'Visitat',
'collections.status.revisit': 'Hi vull tornar',
'collections.status.filterAll': 'Tot',
'collections.status.cycleHint': 'toca per canviar',
+1
View File
@@ -117,6 +117,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Nápad',
'collections.status.want': 'Chci navštívit',
'collections.status.visited': 'Navštíveno',
'collections.status.revisit': 'Chci se vrátit',
'collections.status.filterAll': 'Vše',
'collections.status.cycleHint': 'klepnutím změníte',
+1
View File
@@ -107,6 +107,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Idee',
'collections.status.want': 'Will hin',
'collections.status.visited': 'Besucht',
'collections.status.revisit': 'Will wieder hin',
'collections.status.filterAll': 'Alle',
'collections.status.cycleHint': 'tippen zum Ändern',
+1
View File
@@ -106,6 +106,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Idea',
'collections.status.want': 'Want to go',
'collections.status.visited': 'Visited',
'collections.status.revisit': 'Want to revisit',
'collections.status.filterAll': 'All',
'collections.status.cycleHint': 'tap to change',
+1
View File
@@ -106,6 +106,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Idea', // same-as-en
'collections.status.want': 'Quiero ir',
'collections.status.visited': 'Visitado',
'collections.status.revisit': 'Quiero volver',
'collections.status.filterAll': 'Todos',
'collections.status.cycleHint': 'toca para cambiar',
+1
View File
@@ -106,6 +106,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Idee',
'collections.status.want': 'Tahan minna',
'collections.status.visited': 'Külastatud',
'collections.status.revisit': 'Tahan tagasi minna',
'collections.status.filterAll': 'Kõik',
'collections.status.cycleHint': 'muutmiseks puuduta',
+1
View File
@@ -106,6 +106,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Idée',
'collections.status.want': 'À visiter',
'collections.status.visited': 'Visité',
'collections.status.revisit': 'À revisiter',
'collections.status.filterAll': 'Tous',
'collections.status.cycleHint': 'touchez pour changer',
+1
View File
@@ -106,6 +106,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Ιδέα',
'collections.status.want': 'Θέλω να πάω',
'collections.status.visited': 'Επισκέφθηκα',
'collections.status.revisit': 'Θέλω να ξαναπάω',
'collections.status.filterAll': 'Όλα',
'collections.status.cycleHint': 'πάτησε για αλλαγή',
+1
View File
@@ -113,6 +113,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'רעיון',
'collections.status.want': 'רוצה ללכת',
'collections.status.visited': 'ביקרתי',
'collections.status.revisit': 'רוצה לחזור',
'collections.status.filterAll': 'הכל',
'collections.status.cycleHint': 'הקישו לשינוי',
'collections.markVisited': 'סימון כביקרתי',
+1
View File
@@ -107,6 +107,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Ötlet',
'collections.status.want': 'Szeretnék odamenni',
'collections.status.visited': 'Meglátogatva',
'collections.status.revisit': 'Szeretnék visszamenni',
'collections.status.filterAll': 'Összes',
'collections.status.cycleHint': 'koppints a módosításhoz',
+1
View File
@@ -94,6 +94,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Ide',
'collections.status.want': 'Ingin ke sana',
'collections.status.visited': 'Dikunjungi',
'collections.status.revisit': 'Ingin kembali',
'collections.status.filterAll': 'Semua',
'collections.status.cycleHint': 'ketuk untuk mengubah',
+1
View File
@@ -106,6 +106,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Idea', // same-as-en
'collections.status.want': 'Da visitare',
'collections.status.visited': 'Visitato',
'collections.status.revisit': 'Da rivisitare',
'collections.status.filterAll': 'Tutti',
'collections.status.cycleHint': 'tocca per cambiare',
+1
View File
@@ -93,6 +93,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'アイデア',
'collections.status.want': '行きたい',
'collections.status.visited': '訪問済み',
'collections.status.revisit': 'また行きたい',
'collections.status.filterAll': 'すべて',
'collections.status.cycleHint': 'タップで変更',
+1
View File
@@ -93,6 +93,7 @@ const collection: TranslationStrings = {
'collections.status.idea': '아이디어',
'collections.status.want': '가고 싶은 곳',
'collections.status.visited': '방문함',
'collections.status.revisit': '다시 가고 싶은 곳',
'collections.status.filterAll': '전체',
'collections.status.cycleHint': '탭하여 변경',
+1
View File
@@ -106,6 +106,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Idee',
'collections.status.want': 'Wil heen',
'collections.status.visited': 'Bezocht',
'collections.status.revisit': 'Wil terug',
'collections.status.filterAll': 'Alle',
'collections.status.cycleHint': 'tik om te wijzigen',
+1
View File
@@ -130,6 +130,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Pomysł',
'collections.status.want': 'Chcę odwiedzić',
'collections.status.visited': 'Odwiedzone',
'collections.status.revisit': 'Chcę wrócić',
'collections.status.filterAll': 'Wszystkie',
'collections.status.cycleHint': 'dotknij, aby zmienić',
+1
View File
@@ -129,6 +129,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Идея',
'collections.status.want': 'Хочу посетить',
'collections.status.visited': 'Посещено',
'collections.status.revisit': 'Хочу вернуться',
'collections.status.filterAll': 'Все',
'collections.status.cycleHint': 'нажмите, чтобы изменить',
+1
View File
@@ -118,6 +118,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Nápad',
'collections.status.want': 'Chcem navštíviť',
'collections.status.visited': 'Navštívené',
'collections.status.revisit': 'Chcem sa vrátiť',
'collections.status.filterAll': 'Všetko',
'collections.status.cycleHint': 'klepnutím zmeníte',
+1
View File
@@ -105,6 +105,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Idé',
'collections.status.want': 'Vill besöka',
'collections.status.visited': 'Besökt',
'collections.status.revisit': 'Vill tillbaka',
'collections.status.filterAll': 'Alla',
'collections.status.cycleHint': 'tryck för att ändra',
+1
View File
@@ -94,6 +94,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'ไอเดีย',
'collections.status.want': 'อยากไป',
'collections.status.visited': 'เยี่ยมชมแล้ว',
'collections.status.revisit': 'อยากกลับไปอีก',
'collections.status.filterAll': 'ทั้งหมด',
'collections.status.cycleHint': 'แตะเพื่อเปลี่ยน',
+1
View File
@@ -106,6 +106,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Fikir',
'collections.status.want': 'Gitmek istiyorum',
'collections.status.visited': 'Gidildi',
'collections.status.revisit': 'Tekrar gitmek istiyorum',
'collections.status.filterAll': 'Tümü',
'collections.status.cycleHint': 'değiştirmek için dokun',
+1
View File
@@ -129,6 +129,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Ідея',
'collections.status.want': 'Хочу відвідати',
'collections.status.visited': 'Відвідано',
'collections.status.revisit': 'Хочу повернутися',
'collections.status.filterAll': 'Усі',
'collections.status.cycleHint': 'торкніться, щоб змінити',
+1
View File
@@ -94,6 +94,7 @@ const collection: TranslationStrings = {
'collections.status.idea': 'Ý tưởng',
'collections.status.want': 'Muốn đến',
'collections.status.visited': 'Đã đến',
'collections.status.revisit': 'Muốn quay lại',
'collections.status.filterAll': 'Tất cả',
'collections.status.cycleHint': 'chạm để thay đổi',
+1
View File
@@ -93,6 +93,7 @@ const collection: TranslationStrings = {
'collections.status.idea': '想法',
'collections.status.want': '想去',
'collections.status.visited': '已造訪',
'collections.status.revisit': '想再去',
'collections.status.filterAll': '全部',
'collections.status.cycleHint': '點按切換',
+1
View File
@@ -93,6 +93,7 @@ const collection: TranslationStrings = {
'collections.status.idea': '想法',
'collections.status.want': '想去',
'collections.status.visited': '已去过',
'collections.status.revisit': '想再去',
'collections.status.filterAll': '全部',
'collections.status.cycleHint': '点按切换',
+1 -1
View File
@@ -24,7 +24,7 @@ The following addons are registered in the system (defined in `server/src/db/see
| `roadtrip` | trip | **Road trip**: plans a trip as one continuous drive, with stops along the route, driving times and arrival times that update themselves. Off by default. See [Road-Trip](Road-Trip). |
| `tours` | trip | **Tours**: hikes, city walks, bike, ski, paddle and horse tours with a route, drawn in TREK or imported from GPX, KML or KMZ, as single-day tours or with stages over several days. On by default. See [Tours](Tours). |
| `journey` | global | Trip tracking and travel journal: check-ins, photos, and daily stories. See [Journey-Journal](Journey-Journal). |
| `collections` | global | A personal, server-wide library of saved places in named lists, with idea/want/visited status, categories, and fusion sharing with per-member roles. See [Collections](Collections). |
| `collections` | global | A personal, server-wide library of saved places in named lists, with idea/want/visited/revisit status, categories, and fusion sharing with per-member roles. See [Collections](Collections). |
| `airtrail` | integration | Sync flights from your self-hosted AirTrail instance into trips. |
| `dawarich` | integration | Read the stays and recorded routes of each user's own Dawarich instance, offered as suggestions to confirm. Read-only, nothing is written back. See [Dawarich](Dawarich). |
| `llm_parsing` | integration | **AI Parsing**: an LLM fallback that extracts bookings from confirmation files KDE Itinerary can't read. With a model that reads images it also takes photos and scanned PDFs, and Costs gets **Scan receipt**. Off by default. See [AI-Booking-Import](AI-Booking-Import) and [Budget-Tracking](Budget-Tracking#scanning-a-receipt). |
+2 -1
View File
@@ -1,6 +1,6 @@
# Collections
Collections is a personal, server-wide library of saved places that lives outside of any single trip. Keep multiple named lists of places you have discovered (a "Norway road trip" wishlist, "Best coffee in Lisbon", "Someday"), each place carrying an idea, want-to-go or visited status, and share a list with other users.
Collections is a personal, server-wide library of saved places that lives outside of any single trip. Keep multiple named lists of places you have discovered (a "Norway road trip" wishlist, "Best coffee in Lisbon", "Someday"), each place carrying an idea, want-to-go, visited or want-to-revisit status, and share a list with other users.
> **Admin:** enable Collections in [Admin-Addons](Admin-Addons).
@@ -33,6 +33,7 @@ Every saved place carries a status you can cycle with one tap:
- **Idea**: something you noted but haven't committed to.
- **Want to go**: on the shortlist.
- **Visited**: been there.
- **Want to revisit**: been there and want to go back. It still counts as visited, so **Visited everywhere** leaves it as it is.
Status is a Collections concept and is not carried into a trip when you copy a place.
+1 -1
View File
@@ -112,7 +112,7 @@ Requires `collections:read` or `collections:write` scope.
| `save_place_to_collection` | Save a place into a collection from a raw payload. Returns a duplicate marker instead of saving when a similar place already exists, unless `force` is true. |
| `save_trip_places_to_collection` | Copy one or more existing trip places into a collection. Duplicates are skipped unless `force` is true. |
| `update_collection_place` | Update a saved place's name, address, coordinates, description, notes, status, category, links, tags, labels, image, or move it to another collection (moving out of a list needs the owner or admin role there). |
| `set_collection_place_status` | Set a saved place's status: `idea`, `want`, or `visited`. |
| `set_collection_place_status` | Set a saved place's status: `idea`, `want`, `visited`, or `revisit` (been there and wants to go back; counts as visited). |
| `set_collection_place_status_from_trip` | Set a status on every saved copy of the given trip places, in every list they are on, for marking places visited after a day out. Ids are trip place ids. Lists you may only read are skipped. Returns how many saved places changed and how many of the trip places were found in at least one list. |
| `rate_collection_place` | Set or clear the current user's 1-5 star rating on a saved place. Every member rates independently; pass `null` to remove the vote. |
| `delete_collection_place` | Remove a saved place from its collection. Requires delete permission on the list. |