Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 23 additions & 21 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,6 @@

> Known future work, not yet planned.

### 12. Roulette / spinner selection

Depends on: 11

> Deferred. Manual selection (tap a book → detail modal → confirm) is implemented and
> covers the "select a book" outcome. The spec's roulette spinner is an optional,
> additive way to pick between the 3–5 results and has not been built. A `book-spin`
> CSS keyframe exists in `styles.css` but is currently unused.

#### Tasks

- Implement roulette spinner component (2–4s decelerate easing) over the 3–5 results
- Respect `prefers-reduced-motion` (instant reveal fallback)
- Allow respin and accept
- Wire to the existing result reveal

#### Done when

- Spinner resolves to a single book with correct motion behaviour
- Respin and accept work on mobile

### 16. Book status: mark in progress / completed

Depends on: 11
Expand Down Expand Up @@ -143,6 +122,29 @@ Depends on: 1

> Merged and verified.

### 12. Roulette / spinner selection

Depends on: 11

> Implemented as an optional "Spin for me" control on the results page rather than a
> separate spinner screen. The highlight travels across the existing result cards with
> decelerating steps (~2–3s, within the brand's 2–4s spinner window, via
> `spin-plan.ts`), lands on a random pick, and reuses the unused `book-spin` pulse.
> Manual selection remains the primary path.

#### Tasks

- [x] Roulette spin over the 3–5 results (2–4s decelerating step sequence)
- [x] Respect `prefers-reduced-motion` (instant reveal fallback)
- [x] Respin and accept from the spin result panel
- [x] Wired to the existing result reveal and accept/confirmation flow
- [x] Unit tests for the spin schedule (`spin-plan.spec.ts`)

#### Done when

- [x] Spinner resolves to a single book with correct motion behaviour
- [x] Respin and accept work on mobile

### 19. Quiz flow improvements

Depends on: 11
Expand Down
66 changes: 65 additions & 1 deletion frontend/src/app/features/result/result.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,78 @@ <h1 class="font-display text-2xl text-[var(--color-neutral-800)]">
</p>
</div>

<!-- Roulette spinner -->
@if (picks().length > 1) {
<div class="flex flex-col gap-3">
@if (!spunBook()) {
<button
type="button"
(click)="spin()"
[disabled]="spinning()"
class="h-12 w-full rounded-[var(--radius-md)] border-[1.5px] border-[var(--color-green-500)] text-[var(--color-green-700)] text-sm font-medium transition-colors duration-150 hover:bg-[var(--color-green-50)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-green-500)] disabled:cursor-default disabled:opacity-60"
>
{{ spinning() ? 'Spinning…' : "Can't decide? Spin for me 🎲" }}
</button>
}
<div aria-live="polite">
@if (spunBook() && !spinning()) {
<div
class="book-reveal flex flex-col gap-3 rounded-[var(--radius-lg)] border border-[var(--color-green-200)] bg-[var(--color-green-50)] p-4"
>
<div class="text-center">
<p
class="text-xs font-medium tracking-[0.1em] uppercase text-[var(--color-green-700)]"
>
The spinner chose
</p>
<p
class="mt-1 font-display text-lg text-[var(--color-neutral-800)] leading-snug"
>
{{ spunBook()!.title }}
</p>
@if (spunBook()!.author) {
<p class="mt-0.5 text-xs text-[var(--color-neutral-500)]">
{{ spunBook()!.author }}
</p>
}
</div>
<div class="flex gap-3">
<button
type="button"
(click)="acceptSpun()"
class="h-12 flex-1 rounded-[var(--radius-md)] bg-[var(--color-green-500)] text-white text-sm font-medium transition-colors duration-150 hover:bg-[var(--color-green-600)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-green-500)]"
>
This is the one!
</button>
<button
type="button"
(click)="spin()"
class="h-12 flex-1 rounded-[var(--radius-md)] border-[1.5px] border-[var(--color-green-500)] text-[var(--color-green-700)] text-sm font-medium transition-colors duration-150 hover:bg-[var(--color-green-100)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-green-500)]"
>
Spin again
</button>
</div>
</div>
}
</div>
</div>
}

<!-- Book card list -->
<ul class="flex flex-col gap-3" aria-label="Recommended books">
@for (book of picks(); track book.id; let i = $index) {
<li class="book-reveal" [style.animation-delay]="i * 60 + 'ms'">
<button
type="button"
(click)="openModal(book)"
class="w-full flex items-center gap-4 rounded-[var(--radius-lg)] bg-[var(--color-neutral-50)] border border-[var(--color-neutral-200)] px-4 py-3 text-left transition-all duration-150 hover:border-[var(--color-green-300)] hover:bg-[var(--color-green-50)] hover:shadow-[var(--shadow-md)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-green-500)]"
[disabled]="spinning()"
class="w-full flex items-center gap-4 rounded-[var(--radius-lg)] border px-4 py-3 text-left transition-all duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-green-500)]"
[class]="
highlightIndex() === i
? 'border-[var(--color-green-500)] bg-[var(--color-green-50)] shadow-[var(--shadow-md)]'
: 'border-[var(--color-neutral-200)] bg-[var(--color-neutral-50)] hover:border-[var(--color-green-300)] hover:bg-[var(--color-green-50)] hover:shadow-[var(--shadow-md)]'
"
[class.book-spin]="spinning() && highlightIndex() === i"
[attr.aria-label]="book.title + ' — tap to view details'"
>
<!-- Cover -->
Expand Down
55 changes: 53 additions & 2 deletions frontend/src/app/features/result/result.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { QuizService } from '../quiz/quiz.service';
import { RecommendationsService } from '../quiz/recommendations.service';
import { Book } from '../../core/books.service';
import { SynopsisService, BookDetails } from '../../core/synopsis.service';
import { buildSpinPlan, SpinStep } from './spin-plan';

const MAX_PICKS = 5;

Expand All @@ -28,6 +29,11 @@ export class ResultComponent implements OnInit, OnDestroy {
protected readonly detailsLoading = signal(false);
protected readonly details = signal<BookDetails>({ synopsis: null, genres: null });

protected readonly spinning = signal(false);
protected readonly highlightIndex = signal<number | null>(null);
protected readonly spunBook = signal<Book | null>(null);
private spinTimer: ReturnType<typeof setTimeout> | null = null;

ngOnInit(): void {
const scored = this.quiz.picks();
if (!scored.length) {
Expand All @@ -44,6 +50,7 @@ export class ResultComponent implements OnInit, OnDestroy {
}

ngOnDestroy(): void {
if (this.spinTimer !== null) clearTimeout(this.spinTimer);
this.unlockScroll();
}

Expand Down Expand Up @@ -81,15 +88,59 @@ export class ResultComponent implements OnInit, OnDestroy {

protected accept(): void {
const book = this.selectedBook();
if (book) this.acceptBook(book);
this.closeModal();
}

/** Lets the roulette spinner randomly land on one of the picks. */
protected spin(): void {
const books = this.picks();
if (this.spinning() || books.length < 2) return;

this.spunBook.set(null);
const target = Math.floor(Math.random() * books.length);

// Reduced motion: skip the roulette and reveal the result instantly.
if (this.prefersReducedMotion()) {
this.highlightIndex.set(target);
this.spunBook.set(books[target]);
return;
}

this.spinning.set(true);
const plan = buildSpinPlan(books.length, target, this.highlightIndex() ?? 0);
this.runSpinSteps(plan, 0, target);
}

private runSpinSteps(plan: SpinStep[], step: number, target: number): void {
if (step >= plan.length) {
this.spinning.set(false);
this.spunBook.set(this.picks()[target] ?? null);
return;
}
this.spinTimer = setTimeout(() => {
this.highlightIndex.set(plan[step].index);
this.runSpinSteps(plan, step + 1, target);
}, plan[step].delay);
}

protected acceptSpun(): void {
const book = this.spunBook();
if (book) this.acceptBook(book);
}

private acceptBook(book: Book): void {
this.acceptedBook.set(book);

const id = this.quiz.recommendationId();
if (book && id) {
if (id) {
// Best-effort selection tracking; never blocks the confirmation UI.
void this.recommendations.markSelected(id, book);
}
}

this.closeModal();
private prefersReducedMotion(): boolean {
return this.doc.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches ?? false;
}

protected starsOf(rating: number): string {
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/app/features/result/spin-plan.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { buildSpinPlan } from './spin-plan';

describe('buildSpinPlan', () => {
it('lands on the target index', () => {
for (let count = 2; count <= 5; count++) {
for (let target = 0; target < count; target++) {
const plan = buildSpinPlan(count, target);
expect(plan.at(-1)?.index).toBe(target);
}
}
});

it('lands on the target when starting from a previous highlight', () => {
for (let start = 0; start < 5; start++) {
const plan = buildSpinPlan(5, 2, start);
expect(plan.at(-1)?.index).toBe(2);
}
});

it('advances the highlight one pick at a time', () => {
const count = 4;
const plan = buildSpinPlan(count, 1);
let previous = 0;
for (const step of plan) {
expect(step.index).toBe((previous + 1) % count);
previous = step.index;
}
});

it('decelerates: delays never decrease', () => {
const plan = buildSpinPlan(5, 3);
for (let i = 1; i < plan.length; i++) {
expect(plan[i].delay).toBeGreaterThanOrEqual(plan[i - 1].delay);
}
});

it('keeps the total duration inside the 2–4s brand window', () => {
for (let count = 2; count <= 5; count++) {
for (let target = 0; target < count; target++) {
const total = buildSpinPlan(count, target).reduce((sum, s) => sum + s.delay, 0);
expect(total).toBeGreaterThanOrEqual(2000);
expect(total).toBeLessThanOrEqual(4000);
}
}
});

it('returns an empty plan for fewer than two picks', () => {
expect(buildSpinPlan(0, 0)).toEqual([]);
expect(buildSpinPlan(1, 0)).toEqual([]);
});

it('returns an empty plan for an out-of-range target', () => {
expect(buildSpinPlan(3, -1)).toEqual([]);
expect(buildSpinPlan(3, 3)).toEqual([]);
});
});
35 changes: 35 additions & 0 deletions frontend/src/app/features/result/spin-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export type SpinStep = {
/** Index of the pick to highlight at this step. */
index: number;
/** Milliseconds to wait before moving the highlight to it. */
delay: number;
};

const MIN_STEP_MS = 70;
const MAX_STEP_MS = 380;
const MIN_TRAVEL_STEPS = 14;

/**
* Builds the step sequence for the roulette spinner: the highlight advances
* one pick at a time with ease-in cubic delays, so it starts fast and
* decelerates until it lands on `targetIndex`. Step counts and delays are
* tuned so the total duration stays inside the brand's 2–4s spinner window
* for the 2–5 picks the result page can show.
*/
export function buildSpinPlan(count: number, targetIndex: number, startIndex = 0): SpinStep[] {
if (count < 2 || targetIndex < 0 || targetIndex >= count) return [];

const cycles = Math.ceil(MIN_TRAVEL_STEPS / count);
const offset = (((targetIndex - startIndex) % count) + count) % count;
const total = cycles * count + offset;

const steps: SpinStep[] = [];
for (let i = 0; i < total; i++) {
const progress = i / (total - 1);
steps.push({
index: (startIndex + i + 1) % count,
delay: Math.round(MIN_STEP_MS + (MAX_STEP_MS - MIN_STEP_MS) * progress ** 3),
});
}
return steps;
}
2 changes: 1 addition & 1 deletion goodreads_tbr_recommendation_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ The app is not intended to replace Goodreads. It is designed to make Goodreads m
- **3–5 results** as a card list; tapping a card opens a detail modal with cover, title, author, rating, page count, genres, synopsis, and a "Why this pick" explanation derived from the scoring reasons (mood match, length fit, high rating). Synopsis + genres are fetched on demand from **Open Library** and cached.
- **Quiz guard rails** — the quiz shows an import prompt when the bookshelf is empty, keeps the user on the quiz with a friendly notice when no books match their filters, and restores previous answers when retrying ("Try different answers").
- **Manual selection** → confirmation screen. This satisfies the core outcome ("select a book to read next").
- **Roulette / spinner selection** — built as an optional "Spin for me" control on the results screen (not the separate spinner screen of §14): the highlight travels across the result cards with a 2–4s decelerating sequence, lands on a random pick, and offers accept/respin. Falls back to an instant reveal under `prefers-reduced-motion`.
- **Anonymous tracking** — every visitor gets a persisted anonymous Supabase session. The recommendation (answers + the books shown) and the final selection are written to the `recommendations` table under RLS.

**Not yet built / deferred:**

- **Roulette / spinner selection** (§6, §8 step 6, §11). Manual selection covers the outcome; the spinner is additive and deferred.
- **Mark as in progress / completed** (§6, §8 steps 8–9, §12). Depends on Goodreads write-back, the spec's key risk (§7).
- **Discrete analytics events** (§19). Sessions and selections are persisted to the `recommendations` table, but the named events are not emitted yet.
- **Account-based identity** (email/display name). Auth is anonymous-only; there is no email sign-in.
Expand Down
Loading