an app to share curated trails
sidetrail.app
1/**
2 * Trail Lifecycle Tests
3 *
4 * Scenarios covering trails from creation through deletion,
5 * including how they appear to users and cascade effects.
6 */
7
8import { describe, it, expect } from "vitest";
9import * as queries from "../queries";
10import {
11 emit,
12 emitDelete,
13 ALICE,
14 BOB,
15 CAROL,
16 DAVE,
17 EVE,
18 FRANK,
19 GRACE,
20 setCurrentUser,
21 generateTid,
22} from "./helpers";
23
24const now = () => new Date().toISOString();
25
26describe("Publishing a trail", () => {
27 it("new trail with activity appears in the home feed and creator's profile", async () => {
28 const stopTid = generateTid();
29 const trail = await emit("app.sidetrail.trail", ALICE.did, {
30 $type: "app.sidetrail.trail",
31 title: "My First Trail",
32 description: "A beginner's guide",
33 stops: [
34 { tid: stopTid, title: "Start Here", content: "Welcome!" },
35 { tid: generateTid(), title: "Next Step", content: "Keep going!" },
36 ],
37 accentColor: "#ff5500",
38 backgroundColor: "#fff8f0",
39 createdAt: now(),
40 });
41
42 // Add non-author activity so it appears in home feed
43 await emit("app.sidetrail.walk", BOB.did, {
44 $type: "app.sidetrail.walk",
45 trail: { uri: trail.uri, cid: trail.cid },
46 visitedStops: [stopTid],
47 createdAt: now(),
48 updatedAt: now(),
49 });
50
51 // Appears in home feed (requires non-author activity)
52 const trails = await queries.loadTrails();
53 expect(trails).toHaveLength(1);
54 expect(trails[0].title).toBe("My First Trail");
55 expect(trails[0].creator.handle).toBe(ALICE.handle);
56 expect(trails[0].stopsCount).toBe(2);
57
58 // Appears on creator's profile (doesn't require activity)
59 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle);
60 expect(publishedTrails).toHaveLength(1);
61 expect(publishedTrails[0].title).toBe("My First Trail");
62
63 // Detail page works
64 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
65 expect(detail.header.title).toBe("My First Trail");
66 expect(detail.stops).toHaveLength(2);
67 });
68
69 it("trail without activity appears in home feed as fallback", async () => {
70 // Create a trail with no activity
71 await emit("app.sidetrail.trail", ALICE.did, {
72 $type: "app.sidetrail.trail",
73 title: "Lonely Trail",
74 description: "No one has walked this",
75 stops: [
76 { tid: generateTid(), title: "Stop", content: "Content" },
77 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
78 ],
79 accentColor: "#111",
80 backgroundColor: "#eee",
81 createdAt: now(),
82 });
83
84 // Appears in home feed even without activity
85 const trails = await queries.loadTrails();
86 expect(trails).toHaveLength(1);
87 expect(trails[0].title).toBe("Lonely Trail");
88
89 // Also appears on creator's profile
90 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle);
91 expect(publishedTrails).toHaveLength(1);
92 });
93});
94
95describe("Discovering trails with active walkers", () => {
96 it("trail cards show up to 3 most recent walkers", async () => {
97 const stopTid = generateTid();
98 const trail = await emit("app.sidetrail.trail", ALICE.did, {
99 $type: "app.sidetrail.trail",
100 title: "Popular Trail",
101 description: "Many people walking",
102 stops: [
103 { tid: stopTid, title: "Stop", content: "Content" },
104 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
105 ],
106 accentColor: "#ff0000",
107 backgroundColor: "#ffffff",
108 createdAt: now(),
109 });
110
111 // Four people walk - oldest first
112 await emit("app.sidetrail.walk", BOB.did, {
113 $type: "app.sidetrail.walk",
114 trail: { uri: trail.uri, cid: trail.cid },
115 visitedStops: [stopTid],
116 createdAt: "2024-01-01T10:00:00Z",
117 updatedAt: "2024-01-01T10:00:00Z",
118 });
119
120 await emit("app.sidetrail.walk", CAROL.did, {
121 $type: "app.sidetrail.walk",
122 trail: { uri: trail.uri, cid: trail.cid },
123 visitedStops: [stopTid],
124 createdAt: "2024-01-02T10:00:00Z",
125 updatedAt: "2024-01-02T10:00:00Z",
126 });
127
128 await emit("app.sidetrail.walk", DAVE.did, {
129 $type: "app.sidetrail.walk",
130 trail: { uri: trail.uri, cid: trail.cid },
131 visitedStops: [stopTid],
132 createdAt: "2024-01-03T10:00:00Z",
133 updatedAt: "2024-01-03T10:00:00Z",
134 });
135
136 await emit("app.sidetrail.walk", EVE.did, {
137 $type: "app.sidetrail.walk",
138 trail: { uri: trail.uri, cid: trail.cid },
139 visitedStops: [stopTid],
140 createdAt: "2024-01-04T10:00:00Z",
141 updatedAt: "2024-01-04T10:00:00Z",
142 });
143
144 await emit("app.sidetrail.walk", FRANK.did, {
145 $type: "app.sidetrail.walk",
146 trail: { uri: trail.uri, cid: trail.cid },
147 visitedStops: [stopTid],
148 createdAt: "2024-01-05T10:00:00Z",
149 updatedAt: "2024-01-05T10:00:00Z",
150 });
151
152 await emit("app.sidetrail.walk", GRACE.did, {
153 $type: "app.sidetrail.walk",
154 trail: { uri: trail.uri, cid: trail.cid },
155 visitedStops: [stopTid],
156 createdAt: "2024-01-06T10:00:00Z",
157 updatedAt: "2024-01-06T10:00:00Z",
158 });
159
160 const trails = await queries.loadTrails();
161 const activeWalkers = await queries.loadTrailActiveWalkers(trails[0].uri);
162
163 // Should show exactly 5, most recent first (limit is 5)
164 expect(activeWalkers).toHaveLength(5);
165 // Bob (oldest) should be excluded
166 const handles = activeWalkers.map((w: { handle: string }) => w.handle);
167 expect(handles).not.toContain(BOB.handle);
168 });
169
170 it("active walkers shows most recent activity first", async () => {
171 const stopTid = generateTid();
172 const trail = await emit("app.sidetrail.trail", ALICE.did, {
173 $type: "app.sidetrail.trail",
174 title: "Activity Order Trail",
175 description: "Testing order",
176 stops: [
177 { tid: stopTid, title: "Stop", content: "Content" },
178 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
179 ],
180 accentColor: "#00ff00",
181 backgroundColor: "#f0fff0",
182 createdAt: now(),
183 });
184
185 // Bob walks first, Carol walks later
186 await emit("app.sidetrail.walk", BOB.did, {
187 $type: "app.sidetrail.walk",
188 trail: { uri: trail.uri, cid: trail.cid },
189 visitedStops: [stopTid],
190 createdAt: "2024-01-01T10:00:00Z",
191 updatedAt: "2024-01-01T10:00:00Z",
192 });
193
194 await emit("app.sidetrail.walk", CAROL.did, {
195 $type: "app.sidetrail.walk",
196 trail: { uri: trail.uri, cid: trail.cid },
197 visitedStops: [stopTid],
198 createdAt: "2024-06-01T10:00:00Z",
199 updatedAt: "2024-06-01T10:00:00Z",
200 });
201
202 const trails = await queries.loadTrails();
203 const activeWalkers = await queries.loadTrailActiveWalkers(trails[0].uri);
204
205 expect(activeWalkers[0].handle).toBe(CAROL.handle);
206 });
207});
208
209describe("Viewing a trail's detail page", () => {
210 it("shows walkers at their current stops", async () => {
211 const stop1 = generateTid();
212 const stop2 = generateTid();
213 const stop3 = generateTid();
214
215 const trail = await emit("app.sidetrail.trail", ALICE.did, {
216 $type: "app.sidetrail.trail",
217 title: "Multi-stop Trail",
218 description: "See where everyone is",
219 stops: [
220 { tid: stop1, title: "Beginning", content: "Start here" },
221 { tid: stop2, title: "Middle", content: "Keep going" },
222 { tid: stop3, title: "End", content: "Almost there" },
223 ],
224 accentColor: "#0000ff",
225 backgroundColor: "#f0f0ff",
226 createdAt: now(),
227 });
228
229 // Bob at stop 1, Carol at stop 2, Dave finished (at stop 3)
230 await emit("app.sidetrail.walk", BOB.did, {
231 $type: "app.sidetrail.walk",
232 trail: { uri: trail.uri, cid: trail.cid },
233 visitedStops: [stop1],
234 createdAt: now(),
235 updatedAt: now(),
236 });
237
238 await emit("app.sidetrail.walk", CAROL.did, {
239 $type: "app.sidetrail.walk",
240 trail: { uri: trail.uri, cid: trail.cid },
241 visitedStops: [stop1, stop2],
242 createdAt: now(),
243 updatedAt: now(),
244 });
245
246 await emit("app.sidetrail.walk", DAVE.did, {
247 $type: "app.sidetrail.walk",
248 trail: { uri: trail.uri, cid: trail.cid },
249 visitedStops: [stop1, stop2, stop3],
250 createdAt: now(),
251 updatedAt: now(),
252 });
253
254 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
255
256 const atStop1 = await detail.stops[0].walkersHere;
257 const atStop2 = await detail.stops[1].walkersHere;
258 const atStop3 = await detail.stops[2].walkersHere;
259
260 expect(atStop1.map((w) => w.handle)).toEqual([BOB.handle]);
261 expect(atStop2.map((w) => w.handle)).toEqual([CAROL.handle]);
262 expect(atStop3.map((w) => w.handle)).toEqual([DAVE.handle]);
263 });
264
265 it("shows all walkers including those who completed", async () => {
266 const stopTid = generateTid();
267 const trail = await emit("app.sidetrail.trail", ALICE.did, {
268 $type: "app.sidetrail.trail",
269 title: "Walkers and Completers",
270 description: "Mixed activity",
271 stops: [
272 { tid: stopTid, title: "Stop", content: "Content" },
273 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
274 ],
275 accentColor: "#ff00ff",
276 backgroundColor: "#fff0ff",
277 createdAt: now(),
278 });
279
280 // Bob is actively walking
281 await emit("app.sidetrail.walk", BOB.did, {
282 $type: "app.sidetrail.walk",
283 trail: { uri: trail.uri, cid: trail.cid },
284 visitedStops: [stopTid],
285 createdAt: now(),
286 updatedAt: now(),
287 });
288
289 // Carol completed the trail
290 await emit("app.sidetrail.completion", CAROL.did, {
291 $type: "app.sidetrail.completion",
292 trail: { uri: trail.uri, cid: trail.cid },
293 createdAt: now(),
294 });
295
296 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
297 const walkers = await detail.walkers;
298 const completions = await detail.completions;
299
300 // Both appear in walkers (activity feed)
301 expect(walkers).toHaveLength(2);
302 const walkerHandles = walkers.map((w) => w.user.handle);
303 expect(walkerHandles).toContain(BOB.handle);
304 expect(walkerHandles).toContain(CAROL.handle);
305
306 // Only Carol in completions
307 expect(completions).toHaveLength(1);
308 expect(completions[0].user.handle).toBe(CAROL.handle);
309 });
310
311 it("marks current user in walkers list", async () => {
312 const stopTid = generateTid();
313 const trail = await emit("app.sidetrail.trail", ALICE.did, {
314 $type: "app.sidetrail.trail",
315 title: "IsYou Test",
316 description: "Testing current user marker",
317 stops: [
318 { tid: stopTid, title: "Stop", content: "Content" },
319 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
320 ],
321 accentColor: "#123456",
322 backgroundColor: "#654321",
323 createdAt: now(),
324 });
325
326 await emit("app.sidetrail.walk", BOB.did, {
327 $type: "app.sidetrail.walk",
328 trail: { uri: trail.uri, cid: trail.cid },
329 visitedStops: [stopTid],
330 createdAt: now(),
331 updatedAt: now(),
332 });
333
334 await emit("app.sidetrail.walk", CAROL.did, {
335 $type: "app.sidetrail.walk",
336 trail: { uri: trail.uri, cid: trail.cid },
337 visitedStops: [stopTid],
338 createdAt: now(),
339 updatedAt: now(),
340 });
341
342 setCurrentUser(BOB);
343 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
344 const walkers = await detail.walkers;
345
346 const bob = walkers.find((w) => w.user.handle === BOB.handle);
347 const carol = walkers.find((w) => w.user.handle === CAROL.handle);
348
349 expect(bob?.isYou).toBe(true);
350 expect(carol?.isYou).toBe(false);
351 });
352});
353
354describe("Deleting a trail", () => {
355 it("trail disappears from home feed and author's profile", async () => {
356 const stopTid = generateTid();
357 const trail = await emit("app.sidetrail.trail", ALICE.did, {
358 $type: "app.sidetrail.trail",
359 title: "Ephemeral Trail",
360 description: "Will be deleted",
361 stops: [
362 { tid: stopTid, title: "Stop", content: "Content" },
363 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
364 ],
365 accentColor: "#aaa",
366 backgroundColor: "#bbb",
367 createdAt: now(),
368 });
369
370 // Add non-author activity so it appears in home feed
371 await emit("app.sidetrail.walk", BOB.did, {
372 $type: "app.sidetrail.walk",
373 trail: { uri: trail.uri, cid: trail.cid },
374 visitedStops: [stopTid],
375 createdAt: now(),
376 updatedAt: now(),
377 });
378
379 let trails = await queries.loadTrails();
380 expect(trails).toHaveLength(1);
381
382 await emitDelete("app.sidetrail.trail", trail.uri);
383
384 trails = await queries.loadTrails();
385 expect(trails).toHaveLength(0);
386
387 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle);
388 expect(publishedTrails).toHaveLength(0);
389 });
390
391 it("detail page throws 'Trail not found' after deletion", async () => {
392 const trail = await emit("app.sidetrail.trail", ALICE.did, {
393 $type: "app.sidetrail.trail",
394 title: "Soon Gone",
395 description: "Will 404",
396 stops: [
397 { tid: generateTid(), title: "Stop", content: "Content" },
398 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
399 ],
400 accentColor: "#ccc",
401 backgroundColor: "#ddd",
402 createdAt: now(),
403 });
404
405 await emitDelete("app.sidetrail.trail", trail.uri);
406
407 await expect(queries.loadTrailDetail(ALICE.handle, trail.rkey)).rejects.toThrow(
408 "Trail not found",
409 );
410 });
411
412 it("walkers' in-progress walks disappear when trail is deleted", async () => {
413 const stopTid = generateTid();
414 const trail = await emit("app.sidetrail.trail", ALICE.did, {
415 $type: "app.sidetrail.trail",
416 title: "Disappearing Trail",
417 description: "Walkers will be orphaned",
418 stops: [
419 { tid: stopTid, title: "Stop", content: "Content" },
420 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
421 ],
422 accentColor: "#111",
423 backgroundColor: "#222",
424 createdAt: now(),
425 });
426
427 await emit("app.sidetrail.walk", BOB.did, {
428 $type: "app.sidetrail.walk",
429 trail: { uri: trail.uri, cid: trail.cid },
430 visitedStops: [stopTid],
431 createdAt: now(),
432 updatedAt: now(),
433 });
434
435 setCurrentUser(BOB);
436 let walks = await queries.loadWalks();
437 expect(walks).toHaveLength(1);
438
439 // Trail author deletes the trail
440 await emitDelete("app.sidetrail.trail", trail.uri);
441
442 // Bob's walk is orphaned - doesn't appear in his walks
443 walks = await queries.loadWalks();
444 expect(walks).toHaveLength(0);
445 });
446
447 it("completions disappear from profiles when trail is deleted", async () => {
448 const trail = await emit("app.sidetrail.trail", ALICE.did, {
449 $type: "app.sidetrail.trail",
450 title: "Completed Then Deleted",
451 description: "Completion will be orphaned",
452 stops: [
453 { tid: generateTid(), title: "Stop", content: "Content" },
454 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
455 ],
456 accentColor: "#333",
457 backgroundColor: "#444",
458 createdAt: now(),
459 });
460
461 await emit("app.sidetrail.completion", BOB.did, {
462 $type: "app.sidetrail.completion",
463 trail: { uri: trail.uri, cid: trail.cid },
464 createdAt: now(),
465 });
466
467 let completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
468 expect(completedTrails).toHaveLength(1);
469
470 await emitDelete("app.sidetrail.trail", trail.uri);
471
472 completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
473 expect(completedTrails).toHaveLength(0);
474 });
475
476 it("walking badges disappear when trail is deleted", async () => {
477 const trail = await emit("app.sidetrail.trail", ALICE.did, {
478 $type: "app.sidetrail.trail",
479 title: "Badge Trail",
480 description: "Badge will disappear",
481 stops: [
482 { tid: generateTid(), title: "Stop", content: "Content" },
483 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
484 ],
485 accentColor: "#abcdef",
486 backgroundColor: "#fedcba",
487 createdAt: now(),
488 });
489
490 await emit("app.sidetrail.walk", BOB.did, {
491 $type: "app.sidetrail.walk",
492 trail: { uri: trail.uri, cid: trail.cid },
493 visitedStops: [],
494 createdAt: now(),
495 updatedAt: now(),
496 });
497
498 setCurrentUser(BOB);
499 let badges = await queries.loadWalkingBadges();
500 expect(badges).toHaveLength(1);
501
502 await emitDelete("app.sidetrail.trail", trail.uri);
503
504 badges = await queries.loadWalkingBadges();
505 expect(badges).toHaveLength(0);
506 });
507});
508
509describe("Hotness ranking", () => {
510 it("trail with more recent activity ranks higher", async () => {
511 const stopTidA = generateTid();
512 const stopTidB = generateTid();
513
514 // Two trails
515 const trailA = await emit("app.sidetrail.trail", ALICE.did, {
516 $type: "app.sidetrail.trail",
517 title: "Trail A - Old Activity",
518 description: "Walked a while ago",
519 stops: [
520 { tid: stopTidA, title: "Stop", content: "Content" },
521 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
522 ],
523 accentColor: "#111",
524 backgroundColor: "#eee",
525 createdAt: now(),
526 });
527
528 const trailB = await emit("app.sidetrail.trail", BOB.did, {
529 $type: "app.sidetrail.trail",
530 title: "Trail B - Recent Activity",
531 description: "Just walked",
532 stops: [
533 { tid: stopTidB, title: "Stop", content: "Content" },
534 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
535 ],
536 accentColor: "#222",
537 backgroundColor: "#ddd",
538 createdAt: now(),
539 });
540
541 // Trail A has older activity (7 days ago)
542 const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
543 await emit("app.sidetrail.walk", CAROL.did, {
544 $type: "app.sidetrail.walk",
545 trail: { uri: trailA.uri, cid: trailA.cid },
546 visitedStops: [stopTidA],
547 createdAt: sevenDaysAgo,
548 updatedAt: sevenDaysAgo,
549 });
550
551 // Trail B has recent activity (now)
552 await emit("app.sidetrail.walk", DAVE.did, {
553 $type: "app.sidetrail.walk",
554 trail: { uri: trailB.uri, cid: trailB.cid },
555 visitedStops: [stopTidB],
556 createdAt: now(),
557 updatedAt: now(),
558 });
559
560 const trails = await queries.loadTrails();
561
562 // Trail B with more recent activity ranks higher
563 expect(trails[0].title).toBe("Trail B - Recent Activity");
564 expect(trails[1].title).toBe("Trail A - Old Activity");
565 });
566
567 it("each unique walker contributes to hotness", async () => {
568 const stopTidA = generateTid();
569 const stopTidB = generateTid();
570
571 // Two trails
572 const trailA = await emit("app.sidetrail.trail", ALICE.did, {
573 $type: "app.sidetrail.trail",
574 title: "Trail A - One Walker",
575 description: "One person walking",
576 stops: [
577 { tid: stopTidA, title: "Stop", content: "Content" },
578 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
579 ],
580 accentColor: "#111",
581 backgroundColor: "#eee",
582 createdAt: now(),
583 });
584
585 const trailB = await emit("app.sidetrail.trail", BOB.did, {
586 $type: "app.sidetrail.trail",
587 title: "Trail B - Three Walkers",
588 description: "Three people walking",
589 stops: [
590 { tid: stopTidB, title: "Stop", content: "Content" },
591 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
592 ],
593 accentColor: "#222",
594 backgroundColor: "#ddd",
595 createdAt: now(),
596 });
597
598 // One walker on Trail A
599 await emit("app.sidetrail.walk", CAROL.did, {
600 $type: "app.sidetrail.walk",
601 trail: { uri: trailA.uri, cid: trailA.cid },
602 visitedStops: [stopTidA],
603 createdAt: now(),
604 updatedAt: now(),
605 });
606
607 // Three walkers on Trail B
608 await emit("app.sidetrail.walk", CAROL.did, {
609 $type: "app.sidetrail.walk",
610 trail: { uri: trailB.uri, cid: trailB.cid },
611 visitedStops: [stopTidB],
612 createdAt: now(),
613 updatedAt: now(),
614 });
615
616 await emit("app.sidetrail.walk", DAVE.did, {
617 $type: "app.sidetrail.walk",
618 trail: { uri: trailB.uri, cid: trailB.cid },
619 visitedStops: [stopTidB],
620 createdAt: now(),
621 updatedAt: now(),
622 });
623
624 await emit("app.sidetrail.walk", EVE.did, {
625 $type: "app.sidetrail.walk",
626 trail: { uri: trailB.uri, cid: trailB.cid },
627 visitedStops: [stopTidB],
628 createdAt: now(),
629 updatedAt: now(),
630 });
631
632 const trails = await queries.loadTrails();
633
634 // Trail B with more walkers should rank higher
635 expect(trails[0].title).toBe("Trail B - Three Walkers");
636 expect(trails[1].title).toBe("Trail A - One Walker");
637 });
638
639 it("completions also contribute to hotness", async () => {
640 const stopTidA = generateTid();
641 const stopTidB = generateTid();
642
643 // Two trails
644 const trailA = await emit("app.sidetrail.trail", ALICE.did, {
645 $type: "app.sidetrail.trail",
646 title: "Trail A - Only Walk",
647 description: "Someone is walking",
648 stops: [
649 { tid: stopTidA, title: "Stop", content: "Content" },
650 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
651 ],
652 accentColor: "#111",
653 backgroundColor: "#eee",
654 createdAt: now(),
655 });
656
657 const trailB = await emit("app.sidetrail.trail", BOB.did, {
658 $type: "app.sidetrail.trail",
659 title: "Trail B - Walk Plus Completion",
660 description: "Someone walked and completed",
661 stops: [
662 { tid: stopTidB, title: "Stop", content: "Content" },
663 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
664 ],
665 accentColor: "#222",
666 backgroundColor: "#ddd",
667 createdAt: now(),
668 });
669
670 // Trail A: one walk
671 await emit("app.sidetrail.walk", CAROL.did, {
672 $type: "app.sidetrail.walk",
673 trail: { uri: trailA.uri, cid: trailA.cid },
674 visitedStops: [stopTidA],
675 createdAt: now(),
676 updatedAt: now(),
677 });
678
679 // Trail B: one walk + one completion (more activity)
680 await emit("app.sidetrail.walk", DAVE.did, {
681 $type: "app.sidetrail.walk",
682 trail: { uri: trailB.uri, cid: trailB.cid },
683 visitedStops: [stopTidB],
684 createdAt: now(),
685 updatedAt: now(),
686 });
687
688 await emit("app.sidetrail.completion", EVE.did, {
689 $type: "app.sidetrail.completion",
690 trail: { uri: trailB.uri, cid: trailB.cid },
691 createdAt: now(),
692 });
693
694 const trails = await queries.loadTrails();
695
696 // Trail B with more activity (walk + completion) ranks higher
697 expect(trails[0].title).toBe("Trail B - Walk Plus Completion");
698 expect(trails[1].title).toBe("Trail A - Only Walk");
699 });
700
701 it("trails with activity rank above trails without activity", async () => {
702 const stopTid = generateTid();
703
704 // Create an old trail with recent activity
705 const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
706 const oldTrailWithActivity = await emit("app.sidetrail.trail", ALICE.did, {
707 $type: "app.sidetrail.trail",
708 title: "Old Trail With Activity",
709 description: "Old but has activity",
710 stops: [
711 { tid: stopTid, title: "Stop", content: "Content" },
712 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
713 ],
714 accentColor: "#111",
715 backgroundColor: "#eee",
716 createdAt: sixtyDaysAgo,
717 });
718
719 // Create another newer trail with no activity
720 const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
721 await emit("app.sidetrail.trail", BOB.did, {
722 $type: "app.sidetrail.trail",
723 title: "New Trail No Activity",
724 description: "Newer but no one has walked",
725 stops: [
726 { tid: generateTid(), title: "Stop", content: "Content" },
727 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
728 ],
729 accentColor: "#222",
730 backgroundColor: "#ddd",
731 createdAt: oneDayAgo,
732 });
733
734 // Add activity to the old trail
735 await emit("app.sidetrail.walk", CAROL.did, {
736 $type: "app.sidetrail.walk",
737 trail: { uri: oldTrailWithActivity.uri, cid: oldTrailWithActivity.cid },
738 visitedStops: [stopTid],
739 createdAt: now(),
740 updatedAt: now(),
741 });
742
743 const trails = await queries.loadTrails();
744
745 // Recent activity outweighs creation recency
746 expect(trails).toHaveLength(2);
747 expect(trails[0].title).toBe("Old Trail With Activity");
748 expect(trails[1].title).toBe("New Trail No Activity");
749 });
750
751 it("author's own activity does not contribute to hotness", async () => {
752 const stopTidA = generateTid();
753 const stopTidB = generateTid();
754
755 // Two trails created at the same time
756 const trailA = await emit("app.sidetrail.trail", ALICE.did, {
757 $type: "app.sidetrail.trail",
758 title: "Trail A - Author Self-Walk",
759 description: "Author walked their own trail",
760 stops: [
761 { tid: stopTidA, title: "Stop", content: "Content" },
762 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
763 ],
764 accentColor: "#111",
765 backgroundColor: "#eee",
766 createdAt: now(),
767 });
768
769 const trailB = await emit("app.sidetrail.trail", BOB.did, {
770 $type: "app.sidetrail.trail",
771 title: "Trail B - Non-Author Walk",
772 description: "Someone else walked",
773 stops: [
774 { tid: stopTidB, title: "Stop", content: "Content" },
775 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
776 ],
777 accentColor: "#222",
778 backgroundColor: "#ddd",
779 createdAt: now(),
780 });
781
782 // Trail A: author walks their own trail (shouldn't count toward hotness)
783 await emit("app.sidetrail.walk", ALICE.did, {
784 $type: "app.sidetrail.walk",
785 trail: { uri: trailA.uri, cid: trailA.cid },
786 visitedStops: [stopTidA],
787 createdAt: now(),
788 updatedAt: now(),
789 });
790
791 // Trail B: non-author walks (should count toward hotness)
792 await emit("app.sidetrail.walk", CAROL.did, {
793 $type: "app.sidetrail.walk",
794 trail: { uri: trailB.uri, cid: trailB.cid },
795 visitedStops: [stopTidB],
796 createdAt: now(),
797 updatedAt: now(),
798 });
799
800 const trails = await queries.loadTrails();
801
802 // Both trails appear, but Trail B (with non-author activity) ranks first
803 // since author activity doesn't count toward the score
804 expect(trails).toHaveLength(2);
805 expect(trails[0].title).toBe("Trail B - Non-Author Walk");
806 expect(trails[1].title).toBe("Trail A - Author Self-Walk");
807 });
808});
809
810describe("Empty states", () => {
811 it("home feed is empty when no trails exist", async () => {
812 const trails = await queries.loadTrails();
813 expect(trails).toHaveLength(0);
814 });
815
816 it("trail detail page works even with no activity", async () => {
817 const trail = await emit("app.sidetrail.trail", ALICE.did, {
818 $type: "app.sidetrail.trail",
819 title: "Lonely Trail",
820 description: "Nobody here yet",
821 stops: [
822 { tid: generateTid(), title: "Stop", content: "Content" },
823 { tid: generateTid(), title: "Filler stop", content: "Filler content" },
824 ],
825 accentColor: "#555",
826 backgroundColor: "#666",
827 createdAt: now(),
828 });
829
830 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
831 const walkers = await detail.walkers;
832 const completions = await detail.completions;
833 const activeWalkers = await queries.loadTrailActiveWalkers(trail.uri);
834
835 expect(walkers).toHaveLength(0);
836 expect(completions).toHaveLength(0);
837 expect(activeWalkers).toHaveLength(0);
838 });
839
840 it("non-existent trail throws error", async () => {
841 await expect(queries.loadTrailDetail(ALICE.handle, "nonexistent")).rejects.toThrow(
842 "Trail not found",
843 );
844 });
845});