mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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': 'انقر للتغيير',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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': 'πάτησε για αλλαγή',
|
||||
|
||||
|
||||
@@ -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': 'סימון כביקרתי',
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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': 'タップで変更',
|
||||
|
||||
|
||||
@@ -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': '탭하여 변경',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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ć',
|
||||
|
||||
|
||||
@@ -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': 'нажмите, чтобы изменить',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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': 'แตะเพื่อเปลี่ยน',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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': 'торкніться, щоб змінити',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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': '點按切換',
|
||||
|
||||
|
||||
@@ -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': '点按切换',
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user