Skip to content

Commit 93f8f87

Browse files
committed
Phase 2: AniList OAuth, profile page, local history; CI + Vercel config
Connect AniList via the implicit grant (right fit for a backend-less SPA): AuthService handles the authorize redirect, /auth/callback parses the token fragment, expiry is respected, and the utility bar / mobile header swap to the signed-in identity. The OAuth client ID is runtime- configurable from the profile page so a deployment works without a rebuild. New /profile page: own stats when connected, any public profile via ?u= — status counts, episodes watched, score distribution, genre taste with per-genre means, highest-rated studios. Recent comparisons and groups persist to localStorage and reappear as profile modules and one-click chips on the compare picker. Add GitHub Actions CI (build + unit + e2e) and vercel.json (SPA rewrites, output dir) — import the repo at vercel.com/new to deploy. 14 new unit tests, 4 new e2e specs; suite now 35 unit + 23 e2e.
1 parent c7266ca commit 93f8f87

25 files changed

Lines changed: 1103 additions & 17 deletions

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-node@v4
14+
with:
15+
node-version: 24
16+
cache: npm
17+
- run: npm ci
18+
- run: npm run build
19+
- run: npm test
20+
- run: npx playwright install chromium --with-deps
21+
- run: npm run e2e
22+
- uses: actions/upload-artifact@v4
23+
if: failure()
24+
with:
25+
name: playwright-results
26+
path: test-results/
27+
retention-days: 7

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Compare anime taste between [AniList](https://anilist.co) users. One place that
1212
- 📚 **Shared backlog** — titles in both plan-to-watch lists, ranked by predicted mutual score, with watch-together picks.
1313
- 👥 **Groups** — member stats, a pairwise taste-match heat matrix, and the backlog shared by the whole group.
1414
- 📱 **Mobile layout** — compact summary view with bottom navigation under 720px.
15+
- 🪪 **Profiles & sign-in** — view any public profile's stats (score distribution, genre taste, top studios), or connect your own AniList account via OAuth. Recent comparisons and groups persist locally.
1516

1617
| Shared backlog | Groups | Mobile |
1718
| --- | --- | --- |
@@ -32,3 +33,9 @@ npm run build # production build to dist/
3233
npm test # unit tests (vitest)
3334
npm run e2e # end-to-end tests (playwright, starts its own dev server)
3435
```
36+
37+
## ☁️ Deploy
38+
39+
Built for [Vercel](https://vercel.com) — import the repo at vercel.com/new and the included `vercel.json` handles the SPA rewrites and output directory. CI (build + unit + e2e) runs on every push via GitHub Actions.
40+
41+
To enable "Connect AniList" on a deployment: register an API client at [anilist.co/settings/developer](https://anilist.co/settings/developer) with redirect URL `https://<your-domain>/auth/callback`, then paste the client ID into the one-time setup on the profile page (or set it as the default in `src/app/api/auth.service.ts`).

ROADMAP.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
- [x] User search wired to the header search input
1010

1111
## Phase 2 — Accounts
12-
- [ ] "Connect AniList" OAuth flow
13-
- [ ] My profile page
14-
- [ ] Persist recent comparisons and groups (local first, backend later)
12+
- [x] "Connect AniList" OAuth flow (implicit grant; register a client and paste the ID on the profile page)
13+
- [x] My profile page (own stats when connected, any public profile via `?u=`)
14+
- [x] Persist recent comparisons and groups (localStorage — backend sync later)
1515

1616
## Phase 3 — Depth
1717
- [ ] Recommendations page (predicted mutual scores across the full catalog)
@@ -24,4 +24,5 @@
2424
- [ ] Mobile layouts for Shared backlog and Groups
2525
- [ ] Density switcher (compact / standard / comfortable — tokens already support it)
2626
- [x] E2E smoke tests (Playwright)
27-
- [ ] CI (build + test) and deploy (static hosting)
27+
- [x] CI (GitHub Actions: build + unit + e2e)
28+
- [ ] Deploy to Vercel (`vercel.json` is ready — import the GitHub repo at vercel.com/new)

e2e/profile.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { expect, test } from '@playwright/test';
2+
import { FIXTURES } from './fixtures';
3+
4+
const VIEWER_RESPONSE = {
5+
data: { Viewer: { id: 9, name: 'alice', avatar: { medium: null } } },
6+
};
7+
8+
const mockAnilist = async (page: import('@playwright/test').Page) => {
9+
await page.route('https://graphql.anilist.co/**', async (route) => {
10+
const body = route.request().postDataJSON() as { query: string; variables?: { name?: string } };
11+
if (body.query.includes('Viewer')) {
12+
await route.fulfill({ json: VIEWER_RESPONSE });
13+
return;
14+
}
15+
if (body.query.includes('users(search')) {
16+
await route.fulfill({ json: { data: { Page: { users: [] } } } });
17+
return;
18+
}
19+
const fixture = FIXTURES[body.variables?.name ?? ''];
20+
if (!fixture) {
21+
await route.fulfill({ status: 404, json: { errors: [{ message: 'User not found' }] } });
22+
return;
23+
}
24+
await route.fulfill({ json: fixture });
25+
});
26+
};
27+
28+
test('logged out: connect prompt, unconfigured hint, public profile lookup', async ({ page }) => {
29+
await mockAnilist(page);
30+
await page.goto('/profile');
31+
await expect(page.getByRole('heading', { name: 'My profile' })).toBeVisible();
32+
33+
// no OAuth client configured yet -> connect explains setup
34+
await page.getByRole('button', { name: 'Connect AniList' }).click();
35+
await expect(page.locator('.picker-error')).toContainText('client ID');
36+
37+
// any public profile can still be viewed
38+
await page.getByPlaceholder('AniList username').fill('alice');
39+
await page.getByRole('button', { name: 'View profile' }).click();
40+
await expect(page.getByRole('heading', { name: 'alice' })).toBeVisible();
41+
await expect(page).toHaveURL(/profile\?u=alice/);
42+
await expect(page.getByText('3 completed')).toBeVisible();
43+
await expect(page.getByRole('heading', { name: 'Genre taste' })).toBeVisible();
44+
});
45+
46+
test('auth callback stores the token and profile shows the viewer', async ({ page }) => {
47+
await mockAnilist(page);
48+
await page.goto('/auth/callback#access_token=e2e-token&token_type=Bearer&expires_in=3600');
49+
await expect(page).toHaveURL(/\/profile/);
50+
await expect(page.getByText('Signed in as alice')).toBeVisible();
51+
await expect(page.locator('.you')).toHaveText('YOU');
52+
await expect(page.getByRole('button', { name: 'Log out' })).toBeVisible();
53+
54+
// log out returns to the connect prompt
55+
await page.getByRole('button', { name: 'Log out' }).click();
56+
await expect(page.getByRole('button', { name: 'Connect AniList' })).toBeVisible();
57+
await expect(page.getByText('Connect AniList', { exact: true }).first()).toBeVisible();
58+
});
59+
60+
test('comparisons land in history: profile modules and picker chips', async ({ page }) => {
61+
await mockAnilist(page);
62+
await page.goto('/compare');
63+
await page.getByPlaceholder('first username').fill('alice');
64+
await page.getByPlaceholder('second username').fill('bob');
65+
await page.getByRole('button', { name: 'Compare', exact: true }).click();
66+
await expect(page.locator('.user-name').first()).toHaveText('alice');
67+
68+
await page.goto('/profile');
69+
const recents = page.locator('.recents').first();
70+
await expect(recents.getByText('alice × bob')).toBeVisible();
71+
await expect(recents.getByText(/\/100/)).toBeVisible();
72+
73+
// fresh compare page offers the recent pair as a one-click chip
74+
await page.goto('/compare');
75+
const chip = page.getByRole('button', { name: 'alice × bob' });
76+
await expect(chip).toBeVisible();
77+
await chip.click();
78+
await expect(page.locator('.user-name').first()).toHaveText('alice');
79+
});
80+
81+
test('groups land in history and reopen from profile', async ({ page }) => {
82+
await mockAnilist(page);
83+
await page.goto('/groups?users=alice,bob');
84+
await expect(page.getByRole('columnheader', { name: 'alice' })).toBeVisible();
85+
86+
await page.goto('/profile');
87+
const groupsModule = page.locator('.recents').nth(1);
88+
await expect(groupsModule.getByText('alice, bob')).toBeVisible();
89+
await groupsModule.getByText('alice, bob').click();
90+
await expect(page).toHaveURL(/groups\?users=alice,bob/);
91+
});

src/app/api/anilist.service.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ export interface AnilistUserHit {
3939
completed: number;
4040
}
4141

42+
export interface AnilistViewer {
43+
id: number;
44+
name: string;
45+
avatar: string | null;
46+
}
47+
4248
const USER_LISTS_QUERY = `
4349
query ($name: String) {
4450
MediaListCollection(userName: $name, type: ANIME, forceSingleCompletedList: true) {
@@ -66,6 +72,15 @@ query ($name: String) {
6672
}
6773
}`;
6874

75+
const VIEWER_QUERY = `
76+
query {
77+
Viewer {
78+
id
79+
name
80+
avatar { medium }
81+
}
82+
}`;
83+
6984
const USER_SEARCH_QUERY = `
7085
query ($search: String) {
7186
Page(perPage: 6) {
@@ -176,6 +191,22 @@ export class AnilistService {
176191
};
177192
}
178193

194+
/** Who does this OAuth token belong to? */
195+
async getViewer(token: string): Promise<AnilistViewer> {
196+
const res = await firstValueFrom(
197+
this.http.post<{ data: { Viewer: { id: number; name: string; avatar: { medium: string | null } | null } | null } | null }>(
198+
ANILIST_GRAPHQL,
199+
{ query: VIEWER_QUERY },
200+
{ headers: { Authorization: `Bearer ${token}` } },
201+
),
202+
).catch(() => {
203+
throw new Error('AniList session is invalid or expired — connect again.');
204+
});
205+
const viewer = res.data?.Viewer;
206+
if (!viewer) throw new Error('AniList session is invalid or expired — connect again.');
207+
return { id: viewer.id, name: viewer.name, avatar: viewer.avatar?.medium ?? null };
208+
}
209+
179210
/** Search AniList users by name prefix. */
180211
async searchUsers(search: string): Promise<AnilistUserHit[]> {
181212
const res = await this.gql<GqlSearchResponse>(USER_SEARCH_QUERY, { search }, search);

src/app/api/auth.service.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { AuthService } from './auth.service';
3+
4+
describe('AuthService', () => {
5+
beforeEach(() => {
6+
localStorage.clear();
7+
TestBed.configureTestingModule({});
8+
});
9+
10+
it('parses the implicit-grant fragment and stores the token', () => {
11+
const auth = TestBed.inject(AuthService);
12+
expect(auth.handleCallback('#access_token=abc123&token_type=Bearer&expires_in=31536000')).toBe(true);
13+
expect(auth.token()).toBe('abc123');
14+
expect(auth.connected()).toBe(true);
15+
expect(localStorage.getItem('animatch.token')).toBe('abc123');
16+
});
17+
18+
it('rejects a fragment without a token', () => {
19+
const auth = TestBed.inject(AuthService);
20+
expect(auth.handleCallback('#error=access_denied')).toBe(false);
21+
expect(auth.connected()).toBe(false);
22+
});
23+
24+
it('drops expired tokens on startup', () => {
25+
localStorage.setItem('animatch.token', 'stale');
26+
localStorage.setItem('animatch.tokenExpiry', String(Date.now() - 1000));
27+
const auth = TestBed.inject(AuthService);
28+
expect(auth.token()).toBeNull();
29+
expect(localStorage.getItem('animatch.token')).toBeNull();
30+
});
31+
32+
it('logout clears the session', () => {
33+
const auth = TestBed.inject(AuthService);
34+
auth.handleCallback('#access_token=abc123');
35+
auth.logout();
36+
expect(auth.connected()).toBe(false);
37+
expect(localStorage.getItem('animatch.token')).toBeNull();
38+
});
39+
40+
it('is unconfigured without a client id and refuses login', () => {
41+
const auth = TestBed.inject(AuthService);
42+
expect(auth.configured()).toBe(false);
43+
expect(auth.login()).toBe(false);
44+
auth.setClientId(' 4242 ');
45+
expect(auth.configured()).toBe(true);
46+
expect(auth.authorizeUrl()).toContain('client_id=4242');
47+
expect(auth.authorizeUrl()).toContain('response_type=token');
48+
});
49+
});

src/app/api/auth.service.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Injectable, computed, signal } from '@angular/core';
2+
3+
/**
4+
* AniList OAuth, implicit grant — the right fit for a backend-less SPA.
5+
* Register a client at https://anilist.co/settings/developer with this app's
6+
* URL as the redirect URI (e.g. https://your-app.vercel.app/auth/callback),
7+
* then either set DEFAULT_CLIENT_ID at build time or paste the ID into the
8+
* profile page at runtime (persisted to localStorage).
9+
*/
10+
const DEFAULT_CLIENT_ID = '';
11+
12+
const TOKEN_KEY = 'animatch.token';
13+
const EXPIRY_KEY = 'animatch.tokenExpiry';
14+
const CLIENT_ID_KEY = 'animatch.clientId';
15+
16+
@Injectable({ providedIn: 'root' })
17+
export class AuthService {
18+
readonly token = signal<string | null>(this.readStoredToken());
19+
readonly connected = computed(() => this.token() !== null);
20+
21+
readonly clientId = signal<string>(localStorage.getItem(CLIENT_ID_KEY) || DEFAULT_CLIENT_ID);
22+
readonly configured = computed(() => this.clientId().trim().length > 0);
23+
24+
private readStoredToken(): string | null {
25+
const token = localStorage.getItem(TOKEN_KEY);
26+
if (!token) return null;
27+
const expiry = Number(localStorage.getItem(EXPIRY_KEY) ?? 0);
28+
if (expiry && Date.now() > expiry) {
29+
localStorage.removeItem(TOKEN_KEY);
30+
localStorage.removeItem(EXPIRY_KEY);
31+
return null;
32+
}
33+
return token;
34+
}
35+
36+
setClientId(id: string) {
37+
const trimmed = id.trim();
38+
this.clientId.set(trimmed);
39+
if (trimmed) localStorage.setItem(CLIENT_ID_KEY, trimmed);
40+
else localStorage.removeItem(CLIENT_ID_KEY);
41+
}
42+
43+
authorizeUrl(): string {
44+
return `https://anilist.co/api/v2/oauth/authorize?client_id=${encodeURIComponent(this.clientId())}&response_type=token`;
45+
}
46+
47+
/** Redirect to AniList's consent screen. Returns false when unconfigured. */
48+
login(): boolean {
49+
if (!this.configured()) return false;
50+
window.location.href = this.authorizeUrl();
51+
return true;
52+
}
53+
54+
/**
55+
* Parse the implicit-grant fragment (#access_token=…&expires_in=…) that
56+
* AniList appends to the redirect URI. Returns true when a token landed.
57+
*/
58+
handleCallback(fragment: string): boolean {
59+
const params = new URLSearchParams(fragment.replace(/^#/, ''));
60+
const token = params.get('access_token');
61+
if (!token) return false;
62+
const expiresIn = Number(params.get('expires_in') ?? 0);
63+
localStorage.setItem(TOKEN_KEY, token);
64+
if (expiresIn > 0) {
65+
localStorage.setItem(EXPIRY_KEY, String(Date.now() + expiresIn * 1000));
66+
} else {
67+
localStorage.removeItem(EXPIRY_KEY);
68+
}
69+
this.token.set(token);
70+
return true;
71+
}
72+
73+
logout() {
74+
localStorage.removeItem(TOKEN_KEY);
75+
localStorage.removeItem(EXPIRY_KEY);
76+
this.token.set(null);
77+
}
78+
}

src/app/api/history-store.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { HistoryStore, relativeTime } from './history-store';
3+
4+
describe('HistoryStore', () => {
5+
beforeEach(() => {
6+
localStorage.clear();
7+
TestBed.configureTestingModule({});
8+
});
9+
10+
it('records comparisons newest-first and dedupes pairs', () => {
11+
const store = TestBed.inject(HistoryStore);
12+
store.recordComparison('alice', 'bob', 70);
13+
store.recordComparison('carol', 'dan', 55);
14+
store.recordComparison('alice', 'bob', 72);
15+
expect(store.comparisons().map((c) => c.a)).toEqual(['alice', 'carol']);
16+
expect(store.comparisons()[0].score).toBe(72);
17+
});
18+
19+
it('caps history at 10 entries and persists to localStorage', () => {
20+
const store = TestBed.inject(HistoryStore);
21+
for (let i = 0; i < 13; i++) store.recordComparison(`u${i}`, 'x', i);
22+
expect(store.comparisons()).toHaveLength(10);
23+
const raw = JSON.parse(localStorage.getItem('animatch.recentComparisons')!);
24+
expect(raw).toHaveLength(10);
25+
expect(raw[0].a).toBe('u12');
26+
});
27+
28+
it('dedupes groups regardless of member order', () => {
29+
const store = TestBed.inject(HistoryStore);
30+
store.recordGroup(['alice', 'bob']);
31+
store.recordGroup(['bob', 'alice']);
32+
expect(store.groups()).toHaveLength(1);
33+
});
34+
35+
it('survives corrupt localStorage', () => {
36+
localStorage.setItem('animatch.recentComparisons', '{not json');
37+
const store = TestBed.inject(HistoryStore);
38+
expect(store.comparisons()).toEqual([]);
39+
});
40+
});
41+
42+
describe('relativeTime', () => {
43+
const now = 1_000_000_000_000;
44+
it('formats seconds, minutes, hours, and days', () => {
45+
expect(relativeTime(now - 30_000, now)).toBe('just now');
46+
expect(relativeTime(now - 5 * 60_000, now)).toBe('5m ago');
47+
expect(relativeTime(now - 3 * 3_600_000, now)).toBe('3h ago');
48+
expect(relativeTime(now - 2 * 86_400_000, now)).toBe('2d ago');
49+
});
50+
});

0 commit comments

Comments
 (0)