diff --git a/logocinemate/README.md b/logocinemate/README.md
new file mode 100644
index 0000000..7c039a6
--- /dev/null
+++ b/logocinemate/README.md
@@ -0,0 +1,50 @@
+# Cinemate — logo & app icons
+
+Set complet de icoane pentru web/PWA, generat din sursa SVG. Pune `icons/` si
+`site.webmanifest` in `public/` (radacina servita de Cloudflare Pages).
+
+## Structura
+- `svg/icon.svg` — sursa master, patrat full-bleed (gradient + inima cu play decupat)
+- `svg/favicon.svg` — varianta cu colturi rotunjite (favicon SVG modern)
+- `svg/icon-mono.svg` — o singura culoare, `fill="currentColor"` (mosteneste color din CSS)
+- `svg/lockup.svg` — icon + wordmark pe fundal dark (README / social / header)
+- `icons/` — PNG-uri rasterizate (vezi mai jos)
+- `og-image.png` — 1200x630, pentru share pe social (Open Graph / Twitter)
+- `logo-lockup.png` — lockup orizontal pe fundal transparent (headere)
+
+## Icoane raster (`icons/`)
+| fisier | dimensiune | rol |
+|---|---|---|
+| favicon.ico | 16/32/48 | favicon clasic |
+| favicon-16.png / -32.png / -48.png | 16–48 | favicon PNG |
+| icon-64 / -128 / -256 | 64–256 | uz general |
+| icon-192.png | 192 | PWA |
+| icon-512.png | 512 | PWA / splash |
+| icon-1024.png | 1024 | master raster / store |
+| apple-touch-icon.png | 180 | iOS home screen |
+| maskable-192 / -512 | 192/512 | PWA maskable (safe-zone) |
+| icon-reverse-512 | 512 | semn colorat pe fundal deschis |
+| icon-coral-512 | 512 | mono coral, fundal transparent |
+| icon-mono-white-512 | 512 | mono alb, fundal transparent |
+| icon-mono-dark-512 | 512 | mono inchis, fundal transparent |
+
+## HTML (`
`)
+```html
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Culori de brand
+- Coral `#FF8A5B` -> Rosu `#E23755` (gradient diagonal)
+- Coral solid `#FF6B5C`
+- Fundal dark `#131318`
+- Text deschis `#F5F5F7`
diff --git a/logocinemate/icon-512.png b/logocinemate/icon-512.png
new file mode 100644
index 0000000..ceddd33
Binary files /dev/null and b/logocinemate/icon-512.png differ
diff --git a/logocinemate/icon.svg b/logocinemate/icon.svg
new file mode 100644
index 0000000..bc1b54b
--- /dev/null
+++ b/logocinemate/icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/logocinemate/logo-lockup.png b/logocinemate/logo-lockup.png
new file mode 100644
index 0000000..5dc771e
Binary files /dev/null and b/logocinemate/logo-lockup.png differ
diff --git a/logocinemate/og-image.png b/logocinemate/og-image.png
new file mode 100644
index 0000000..05fd223
Binary files /dev/null and b/logocinemate/og-image.png differ
diff --git a/logocinemate/site.webmanifest b/logocinemate/site.webmanifest
new file mode 100644
index 0000000..beee2d3
--- /dev/null
+++ b/logocinemate/site.webmanifest
@@ -0,0 +1,15 @@
+{
+ "name": "Cinemate",
+ "short_name": "Cinemate",
+ "description": "Swipe. Match. Watch. Tinder pentru filme si seriale.",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#131318",
+ "theme_color": "#E23755",
+ "icons": [
+ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
+ { "src": "/icons/maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
+ { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}
diff --git a/src/lib/deck.ts b/src/lib/deck.ts
index d0339ff..0c7858f 100644
--- a/src/lib/deck.ts
+++ b/src/lib/deck.ts
@@ -22,6 +22,10 @@ const MAX_DECK = 300;
const TOPUP_PAGES = 2;
// Rotated across generation slices + top-up rounds so the pool isn't all "popular".
const SORTS = ["popularity.desc", "vote_average.desc", "primary_release_date.desc"];
+// Adaptive top-up: the N most-recent liked titles become live seeds, each expanded
+// into up to RECS_PER_SEED recommendations. Kept small → a few cached TMDb calls.
+const LIKED_SEEDS_PER_TOPUP = 3;
+const RECS_PER_SEED = 8;
const DECK_CARDS_KV_PREFIX = "deck-cards:";
const DECK_CARDS_TTL = 60 * 60 * 24 * 7; // 7 days
@@ -35,6 +39,29 @@ export function topupCursor(poolSize: number): { startPage: number; sortBy: stri
return { startPage, sortBy: SORTS[startPage % SORTS.length] };
}
+/**
+ * Drop cards in any avoided genre. TMDb recommendations/similar are NOT genre-filtered
+ * by the API, so callers that use them must re-apply avoid_genres. Pure → unit-tested.
+ */
+export function rejectAvoidGenres(cards: DeckCard[], avoidGenres: number[]): DeckCard[] {
+ if (avoidGenres.length === 0) return cards;
+ const av = new Set(avoidGenres);
+ return cards.filter((c) => !c.genres.some((g) => av.has(g)));
+}
+
+/** Most-recent liked tmdb_ids in the room (both users → shared pool; solo = the one user). */
+async function recentLikedIds(env: Env, roomId: string, limit: number): Promise {
+ const { results } = await env.DB.prepare(
+ `SELECT tmdb_id FROM swipes
+ WHERE room_id = ? AND direction = 'like'
+ ORDER BY created_at DESC
+ LIMIT ?`,
+ )
+ .bind(roomId, limit)
+ .all<{ tmdb_id: number }>();
+ return (results ?? []).map((r) => r.tmdb_id);
+}
+
async function loadProfile(env: Env, userId: string | null): Promise {
if (!userId) return null;
const row = await env.DB.prepare("SELECT * FROM profiles WHERE user_id = ?")
@@ -136,11 +163,7 @@ async function generatePool(
}),
);
// Recommendations aren't genre-filtered by the API → enforce avoid_genres here.
- if (avoidGenres.length > 0) {
- const av = new Set(avoidGenres);
- return cards.filter((c) => !c.genres.some((g) => av.has(g)));
- }
- return cards;
+ return rejectAvoidGenres(cards, avoidGenres);
}
// No applicable seeds → union of genre slices (per-user tastes + common ground).
@@ -266,11 +289,12 @@ export async function getDeckForUser(
}
/**
- * Top up the shared pool on demand: fetch the next TMDb page(s) of the common-ground
- * slice, dedupe against the existing pool, persist the extended pool (D1 + KV), and
- * return ONLY the new cards for THIS user (their swipes + Overseerr ids excluded).
- * Capped at MAX_DECK so the pool — and TMDb/KV/D1 usage — stays bounded on the free tier.
- * Both users share the same extended pool, so matches remain possible.
+ * Top up the shared pool on demand. Adaptive: the room's most-recent likes (both users)
+ * are expanded into TMDb recommendations and mixed with a genre-discovery slice, so the
+ * deck leans toward what's being liked. Deduped against the existing pool, persisted
+ * (D1 + KV), and only the new cards for THIS user are returned (their swipes + Overseerr
+ * ids excluded). Capped at MAX_DECK so TMDb/KV/D1 usage stays bounded on the free tier.
+ * Recommendations go into the SHARED pool, so matches remain possible (and solo works too).
*/
export async function extendDeck(
env: Env,
@@ -293,8 +317,20 @@ export async function extendDeck(
loadProfile(env, room.user_b_id),
]);
const avoidGenres = unionAvoidGenres(profileA, profileB);
- const { startPage, sortBy } = topupCursor(existingIds.size);
+ // Adaptive: expand the room's most-recent likes into TMDb recommendations, so the deck
+ // leans toward what's actually being liked. Likes come from BOTH users → they land in
+ // the shared pool, so matches survive; in a solo room it's simply this one user's likes.
+ const likedIds = await recentLikedIds(env, room.id, LIKED_SEEDS_PER_TOPUP);
+ const recLists = await Promise.all(
+ likedIds.map((id) => getRecommendations(env, room.media_type, id)),
+ );
+ const recCards = recLists.flatMap((recs) =>
+ rejectAvoidGenres(recs, avoidGenres).slice(0, RECS_PER_SEED),
+ );
+
+ // Genre-based discovery as filler + variety (deeper page each round, rotated sort).
+ const { startPage, sortBy } = topupCursor(existingIds.size);
const fresh = await discoverTitles(env, {
mediaType: room.media_type,
genreIds: combineTopGenres(profileA, profileB),
@@ -305,8 +341,9 @@ export async function extendDeck(
sortBy,
});
+ // Personalized recommendations first, then discovery — deduped into the shared pool.
const added: DeckCard[] = [];
- for (const c of fresh) {
+ for (const c of [...recCards, ...fresh]) {
if (existingIds.size >= MAX_DECK) break;
if (existingIds.has(c.tmdb_id)) continue;
existingIds.add(c.tmdb_id);
diff --git a/test/unit.test.ts b/test/unit.test.ts
index 458ac32..67a5e42 100644
--- a/test/unit.test.ts
+++ b/test/unit.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { genJoinCode, genId } from "../src/lib/ids";
import { mapProfile, mapRoom } from "../src/lib/mappers";
-import { topupCursor } from "../src/lib/deck";
+import { topupCursor, rejectAvoidGenres } from "../src/lib/deck";
describe("ids", () => {
it("genJoinCode is 6 chars from the safe alphabet", () => {
@@ -53,3 +53,27 @@ describe("deck top-up cursor", () => {
expect(sorts.every((s) => typeof s === "string" && s.includes("."))).toBe(true);
});
});
+
+describe("rejectAvoidGenres (adaptive top-up filter)", () => {
+ const card = (tmdb_id: number, genres: number[]) => ({
+ tmdb_id,
+ media_type: "movie" as const,
+ title: `t${tmdb_id}`,
+ overview: "",
+ poster_path: null,
+ genres,
+ release_year: null,
+ vote_average: null,
+ });
+
+ it("drops cards that contain any avoided genre", () => {
+ const cards = [card(1, [28, 12]), card(2, [27]), card(3, [35, 27]), card(4, [18])];
+ const kept = rejectAvoidGenres(cards, [27]); // 27 = Horror
+ expect(kept.map((c) => c.tmdb_id)).toEqual([1, 4]);
+ });
+
+ it("returns everything when no genres are avoided", () => {
+ const cards = [card(1, [28]), card(2, [27])];
+ expect(rejectAvoidGenres(cards, [])).toHaveLength(2);
+ });
+});