Skip to content

Commit 0f37460

Browse files
committed
feat(examples-chat): port ItineraryStore — empty start, value hydration, no localStorage
1 parent e55515c commit 0f37460

2 files changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// SPDX-License-Identifier: MIT
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { ItineraryStore } from './itinerary-store';
4+
5+
describe('ItineraryStore', () => {
6+
it('add appends a stop', () => {
7+
const s = new ItineraryStore();
8+
s.add(2, 'Sainte-Chapelle', 'morning');
9+
expect(s.stops().some((x) => x.place === 'Sainte-Chapelle')).toBe(true);
10+
});
11+
12+
it('move matches place case-insensitively and returns the stop', () => {
13+
const s = new ItineraryStore();
14+
s.add(1, 'Louvre');
15+
const moved = s.move('louvre', 2);
16+
expect(moved?.day).toBe(2);
17+
expect(s.move('atlantis', 1)).toBeUndefined();
18+
});
19+
20+
it('clearDay removes only stops for that day', () => {
21+
const s = new ItineraryStore();
22+
s.add(1, 'Louvre');
23+
s.add(1, 'Eiffel Tower');
24+
s.add(2, "Musée d'Orsay");
25+
const removed = s.clearDay(1);
26+
expect(removed).toBe(2);
27+
expect(s.stops().every((x) => x.day !== 1)).toBe(true);
28+
// day 2 stop still exists
29+
expect(s.stops().some((x) => x.day === 2)).toBe(true);
30+
});
31+
32+
it('remove deletes the stop with the given id', () => {
33+
const s = new ItineraryStore();
34+
s.add(1, 'Louvre');
35+
s.add(1, 'Eiffel Tower');
36+
const first = s.stops()[0];
37+
s.remove(first.id);
38+
expect(s.stops().find((x) => x.id === first.id)).toBeUndefined();
39+
expect(s.stops().length).toBe(1);
40+
});
41+
42+
it('reset clears all stops', () => {
43+
const s = new ItineraryStore();
44+
s.add(3, 'Versailles');
45+
s.reset();
46+
expect(s.stops().length).toBe(0);
47+
});
48+
});
49+
50+
describe('ItineraryStore — server-state model', () => {
51+
it('starts empty (no seed)', () => {
52+
const store = new ItineraryStore();
53+
expect(store.stops()).toEqual([]);
54+
expect(store.days()).toEqual([]);
55+
});
56+
57+
it('hydrates from a server itinerary snapshot', () => {
58+
const store = new ItineraryStore();
59+
store.hydrate([{ id: 'x', day: 1, place: 'Louvre' }]);
60+
expect(store.stops().map((s) => s.place)).toEqual(['Louvre']);
61+
});
62+
63+
it('does not touch localStorage on update', () => {
64+
const spy = vi.spyOn(Storage.prototype, 'setItem');
65+
const store = new ItineraryStore();
66+
store.add(1, 'Eiffel Tower');
67+
expect(spy).not.toHaveBeenCalled();
68+
spy.mockRestore();
69+
});
70+
});
71+
72+
describe('reorder', () => {
73+
it('moves a stop to a new index within the same day', () => {
74+
const s = new ItineraryStore();
75+
s.add(1, 'Louvre');
76+
s.add(1, 'Eiffel Tower');
77+
const eiffel = s.stops().find((x) => x.place === 'Eiffel Tower')!;
78+
s.reorder(eiffel.id, 1, 0);
79+
const day1 = s.days().find((g) => g.day === 1)!;
80+
expect(day1.stops.map((x) => x.place)).toEqual(['Eiffel Tower', 'Louvre']);
81+
});
82+
83+
it('moves a stop across days at a specific index', () => {
84+
const s = new ItineraryStore();
85+
s.add(1, 'Louvre');
86+
s.add(1, 'Eiffel Tower');
87+
s.add(2, "Musée d'Orsay");
88+
const orsay = s.stops().find((x) => x.place === "Musée d'Orsay")!;
89+
s.reorder(orsay.id, 1, 0);
90+
const day1 = s.days().find((g) => g.day === 1)!;
91+
expect(day1.stops[0].place).toBe("Musée d'Orsay");
92+
expect(day1.stops[0].day).toBe(1);
93+
});
94+
95+
it('reorder by unknown id is a no-op', () => {
96+
const s = new ItineraryStore();
97+
s.add(1, 'Louvre');
98+
const before = s.stops();
99+
s.reorder('does-not-exist', 1, 0);
100+
expect(s.stops()).toEqual(before);
101+
});
102+
});
103+
104+
describe('recentlyChangedId', () => {
105+
it('is null initially', () => {
106+
const s = new ItineraryStore();
107+
expect(s.recentlyChangedId()).toBeNull();
108+
});
109+
110+
it('is set after an agent-source add', () => {
111+
const s = new ItineraryStore();
112+
const added = s.add(3, 'Sacré-Cœur');
113+
expect(s.recentlyChangedId()).toBe(added.id);
114+
});
115+
116+
it('is NOT set after a user-source add', () => {
117+
const s = new ItineraryStore();
118+
s.add(3, 'Sacré-Cœur', undefined, { source: 'user' });
119+
expect(s.recentlyChangedId()).toBeNull();
120+
});
121+
122+
it('clears 1600ms after the change', async () => {
123+
vi.useFakeTimers();
124+
const s = new ItineraryStore();
125+
s.add(3, 'Sacré-Cœur');
126+
expect(s.recentlyChangedId()).not.toBeNull();
127+
vi.advanceTimersByTime(1600);
128+
expect(s.recentlyChangedId()).toBeNull();
129+
vi.useRealTimers();
130+
});
131+
});
132+
133+
describe('focus', () => {
134+
it('focus sets and clears focusedStopId', () => {
135+
const s = new ItineraryStore();
136+
s.add(1, 'Louvre');
137+
const id = s.stops()[0].id;
138+
expect(s.focusedStopId()).toBeNull();
139+
s.focus(id);
140+
expect(s.focusedStopId()).toBe(id);
141+
s.focus(null);
142+
expect(s.focusedStopId()).toBeNull();
143+
});
144+
});
145+
146+
describe('coordinates', () => {
147+
it('add accepts optional lat/lng via opts.coords', () => {
148+
const s = new ItineraryStore();
149+
const added = s.add(2, 'Sacré-Cœur', undefined, {
150+
coords: { lat: 48.8867, lng: 2.3431 },
151+
});
152+
expect(added.lat).toBeCloseTo(48.8867, 3);
153+
expect(added.lng).toBeCloseTo(2.3431, 3);
154+
});
155+
156+
it('add without coords leaves lat/lng undefined', () => {
157+
const s = new ItineraryStore();
158+
const added = s.add(2, 'Somewhere');
159+
expect(added.lat).toBeUndefined();
160+
expect(added.lng).toBeUndefined();
161+
});
162+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// SPDX-License-Identifier: MIT
2+
import { computed, signal } from '@angular/core';
3+
4+
export interface ItineraryStop {
5+
id: string;
6+
day: number;
7+
place: string;
8+
note?: string;
9+
lat?: number;
10+
lng?: number;
11+
}
12+
13+
export interface MutationOptions {
14+
source?: 'user' | 'agent';
15+
coords?: { lat: number; lng: number };
16+
}
17+
18+
const PULSE_MS = 1600;
19+
20+
/** Working copy of the itinerary: the user edits it in the panel, the agent
21+
* edits it through client tools. Both write the same signals, so either's
22+
* changes render immediately. The graph checkpoint is the durable record —
23+
* the shell calls `hydrate()` from a server state snapshot on thread switch. */
24+
export class ItineraryStore {
25+
readonly stops = signal<ItineraryStop[]>([]);
26+
readonly days = computed(() => {
27+
const byDay = new Map<number, ItineraryStop[]>();
28+
for (const s of this.stops()) byDay.set(s.day, [...(byDay.get(s.day) ?? []), s]);
29+
return [...byDay.entries()]
30+
.sort(([a], [b]) => a - b)
31+
.map(([day, stops]) => ({ day, stops }));
32+
});
33+
readonly recentlyChangedId = signal<string | null>(null);
34+
readonly focusedStopId = signal<string | null>(null);
35+
private pulseTimer: ReturnType<typeof setTimeout> | null = null;
36+
37+
focus(id: string | null): void {
38+
this.focusedStopId.set(id);
39+
}
40+
41+
add(day: number, place: string, note?: string, opts?: MutationOptions): ItineraryStop {
42+
const stop: ItineraryStop = {
43+
id: crypto.randomUUID(),
44+
day,
45+
place,
46+
...(note ? { note } : {}),
47+
...(opts?.coords ? { lat: opts.coords.lat, lng: opts.coords.lng } : {}),
48+
};
49+
this.update([...this.stops(), stop]);
50+
this.flagChanged(stop.id, opts);
51+
return stop;
52+
}
53+
54+
move(place: string, toDay: number, opts?: MutationOptions): ItineraryStop | undefined {
55+
const target = this.stops().find((s) => s.place.toLowerCase() === place.toLowerCase());
56+
if (!target) return undefined;
57+
const moved = { ...target, day: toDay };
58+
this.update(this.stops().map((s) => (s.id === target.id ? moved : s)));
59+
this.flagChanged(moved.id, opts);
60+
return moved;
61+
}
62+
63+
reorder(stopId: string, toDay: number, toIndex: number, opts?: MutationOptions): void {
64+
const current = this.stops();
65+
const target = current.find((s) => s.id === stopId);
66+
if (!target) return;
67+
const without = current.filter((s) => s.id !== stopId);
68+
const dayStops = without.filter((s) => s.day === toDay);
69+
const others = without.filter((s) => s.day !== toDay);
70+
const clampedIndex = Math.max(0, Math.min(toIndex, dayStops.length));
71+
const newDayStops = [
72+
...dayStops.slice(0, clampedIndex),
73+
{ ...target, day: toDay },
74+
...dayStops.slice(clampedIndex),
75+
];
76+
this.update([...others, ...newDayStops]);
77+
this.flagChanged(stopId, opts);
78+
}
79+
80+
remove(id: string, opts?: MutationOptions): void {
81+
this.update(this.stops().filter((s) => s.id !== id));
82+
this.flagChanged(id, opts);
83+
}
84+
85+
clearDay(day: number, opts?: MutationOptions): number {
86+
const removed = this.stops().filter((s) => s.day === day).length;
87+
this.update(this.stops().filter((s) => s.day !== day));
88+
if (removed > 0) this.flagChanged(null, opts);
89+
return removed;
90+
}
91+
92+
reset(opts?: MutationOptions): void {
93+
this.update([]);
94+
this.flagChanged(null, opts);
95+
}
96+
97+
private flagChanged(id: string | null, opts?: MutationOptions): void {
98+
if (opts?.source === 'user') return;
99+
if (this.pulseTimer) clearTimeout(this.pulseTimer);
100+
this.recentlyChangedId.set(id);
101+
if (id !== null) {
102+
this.pulseTimer = setTimeout(() => {
103+
this.recentlyChangedId.set(null);
104+
this.pulseTimer = null;
105+
}, PULSE_MS);
106+
}
107+
}
108+
109+
/** Replace the working copy from a server state snapshot (thread switch /
110+
* values stream). Public — the shell calls it when agent.values() changes. */
111+
hydrate(stops: ItineraryStop[]): void {
112+
this.stops.set(stops ?? []);
113+
}
114+
115+
private update(next: ItineraryStop[]): void {
116+
this.stops.set(next);
117+
}
118+
}

0 commit comments

Comments
 (0)