From 59b147f5ee288b3b103865d0b79745daa9192234 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 25 Apr 2026 13:58:43 +0200 Subject: [PATCH] feat(visibility): embed resolvers for habits/quiz/social-events + inspector refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop on the M5-Rest visibility rollout — flipping a habit, quiz, or social-event to 'public' now actually surfaces it on the owner's website embed. EmbedSourceSchema gains three new sources: - habits.habits — build-in-public widget. Title + "🔥 N Tage Streak · gesamt M ×". Per-log timestamps + notes stay private (sleep/intake patterns are not for public consumption). - quiz.quizzes — shareable-quiz teaser. Title + "N Fragen · {category}". Questions, options, explanations, attempts/scores all stay private — the actual play-experience is reserved for a future unlisted-share flow. - events.socialEvents — RSVP-event teaser. Title + formatted start date + location + cover image. Hard-gated on the unified `visibility` only; the legacy `isPublished` flag is intentionally bypassed so the new Picker is the single source of truth (M6 will drop isPublished). ModuleEmbedInspectorFallback now lists all 12 sources — was only exposing 2 of the 9 already-wired ones (latent debt unblocking the new sources from being addable in the editor). Note: 7 unrelated svelte-check errors exist in data/scope/dedup-workbench-scenes.test.ts from a parallel session (spaceId not on LocalWorkbenchScene). Not introduced here. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/lib/modules/website/embeds.ts | 203 ++++++++++++++++++ .../ModuleEmbedInspectorFallback.svelte | 10 + .../website-blocks/src/moduleEmbed/schema.ts | 3 + 3 files changed, 216 insertions(+) diff --git a/apps/mana/apps/web/src/lib/modules/website/embeds.ts b/apps/mana/apps/web/src/lib/modules/website/embeds.ts index e209e86a9..8d6d19761 100644 --- a/apps/mana/apps/web/src/lib/modules/website/embeds.ts +++ b/apps/mana/apps/web/src/lib/modules/website/embeds.ts @@ -31,6 +31,9 @@ import type { LocalPlace } from '$lib/modules/places/types'; import type { LocalRecipe } from '$lib/modules/recipes/types'; import type { LocalWardrobeOutfit } from '$lib/modules/wardrobe/types'; import type { LocalComicStory } from '$lib/modules/comic/types'; +import type { LocalHabit, LocalHabitLog } from '$lib/modules/habits/types'; +import type { LocalQuiz } from '$lib/modules/quiz/types'; +import type { LocalSocialEvent } from '$lib/modules/events/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; export interface ResolvedEmbed { @@ -72,6 +75,15 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise { + let habits = await db.table('habits').toArray(); + habits = habits.filter( + (h) => !h.deletedAt && !h.isArchived && canEmbedOnWebsite(h.visibility ?? 'private') + ); + + if (props.filter?.tagIds?.length) { + // Habits don't have direct tagIds today; the filter is reserved + // for when they do. Skip silently. + } + + if (habits.length === 0) return []; + + const habitIds = new Set(habits.map((h) => h.id)); + const allLogs = await db.table('habitLogs').toArray(); + const logsByHabit = new Map(); + for (const log of allLogs) { + if (log.deletedAt || !habitIds.has(log.habitId)) continue; + const list = logsByHabit.get(log.habitId) ?? []; + list.push(log); + logsByHabit.set(log.habitId, list); + } + + // Resolve each log's date via its TimeBlock (date-only, the time + // dimension itself is intentionally not exposed). + const blockIds = allLogs.map((l) => l.timeBlockId).filter(Boolean); + const blocks = + blockIds.length > 0 + ? await db.table('timeBlocks').where('id').anyOf(blockIds).toArray() + : []; + const blockDateById = new Map(); + for (const b of blocks) blockDateById.set(b.id, (b.startDate ?? '').slice(0, 10)); + + function streakFor(logs: LocalHabitLog[]): number { + const dates = new Set(); + for (const l of logs) { + const d = blockDateById.get(l.timeBlockId); + if (d) dates.add(d); + } + let streak = 0; + const cursor = new Date(); + while (true) { + const key = cursor.toISOString().slice(0, 10); + if (!dates.has(key)) break; + streak++; + cursor.setDate(cursor.getDate() - 1); + } + return streak; + } + + const decrypted = (await decryptRecords('habits', habits)) as LocalHabit[]; + + // Active streak first, then total-count. + const enriched = decrypted.map((h) => { + const logs = logsByHabit.get(h.id) ?? []; + return { habit: h, streak: streakFor(logs), total: logs.length }; + }); + enriched.sort((a, b) => b.streak - a.streak || b.total - a.total); + + return enriched.map(({ habit, streak, total }) => { + const parts: string[] = []; + if (streak > 0) parts.push(`🔥 ${streak} ${streak === 1 ? 'Tag' : 'Tage'} Streak`); + if (total > 0) parts.push(`gesamt ${total} ×`); + return { + title: habit.title, + subtitle: parts.length > 0 ? parts.join(' · ') : undefined, + }; + }); +} + +/** + * Quizzes: shareable quiz collection use case. Returns quizzes flipped + * to 'public' with their question count + category as subtitle. + * + * Whitelist: title + "N Fragen · {category}" line — questions, options, + * explanations, attempts/scores all stay private. The teaser exists + * to drive opens; the unlisted-share flow (future) would carry the + * actual play-experience. + */ +async function resolveQuizzes(props: ModuleEmbedProps): Promise { + let quizzes = await db.table('quizzes').toArray(); + quizzes = quizzes.filter( + (q) => !q.deletedAt && !q.isArchived && canEmbedOnWebsite(q.visibility ?? 'private') + ); + + if (props.filter?.tagIds?.length) { + const wanted = new Set(props.filter.tagIds); + quizzes = quizzes.filter((q) => (q.tags ?? []).some((t) => wanted.has(t))); + } + + const decrypted = (await decryptRecords('quizzes', quizzes)) as LocalQuiz[]; + + // Pinned first, then newest. + decrypted.sort((a, b) => { + const pinA = a.isPinned ? 0 : 1; + const pinB = b.isPinned ? 0 : 1; + if (pinA !== pinB) return pinA - pinB; + return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''); + }); + + return decrypted.map((q) => { + const count = q.questionCount ?? 0; + const parts: string[] = [`${count} ${count === 1 ? 'Frage' : 'Fragen'}`]; + if (q.category) parts.push(q.category); + return { + title: q.title, + subtitle: parts.join(' · '), + }; + }); +} + +/** + * Social-events (the events module — distinct from calendar.events). + * Returns events flipped to 'public' with their date + location as + * subtitle. Hard-gated on the unified `visibility`; the legacy + * `isPublished` flag is intentionally NOT consulted here so the new + * Picker is the single source of truth (M6 cleanup will drop the + * legacy field entirely). + * + * Whitelist: title + formatted date + location — guest list, RSVP + * counts, capacity, host contact, items/bring-list all stay private. + */ +async function resolveSocialEvents(props: ModuleEmbedProps): Promise { + let events = await db.table('socialEvents').toArray(); + events = events.filter( + (e) => !e.deletedAt && e.status !== 'cancelled' && canEmbedOnWebsite(e.visibility ?? 'private') + ); + + if (events.length === 0) return []; + + // Resolve dates via TimeBlock — the time dimension lives there. + const blockIds = events.map((e) => e.timeBlockId).filter(Boolean); + const blocks = + blockIds.length > 0 + ? await db.table('timeBlocks').where('id').anyOf(blockIds).toArray() + : []; + const blockById = new Map(); + for (const b of blocks) blockById.set(b.id, b); + + if (props.filter?.upcomingDays !== undefined) { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() + props.filter.upcomingDays); + const cutoffISO = cutoff.toISOString(); + const nowISO = new Date().toISOString(); + events = events.filter((e) => { + const start = blockById.get(e.timeBlockId)?.startDate; + return start && start >= nowISO && start <= cutoffISO; + }); + } + + const decrypted = (await decryptRecords('socialEvents', events)) as LocalSocialEvent[]; + + // Upcoming-first by start date. + decrypted.sort((a, b) => { + const sa = blockById.get(a.timeBlockId)?.startDate ?? ''; + const sb = blockById.get(b.timeBlockId)?.startDate ?? ''; + return sa.localeCompare(sb); + }); + + return decrypted.map((e) => { + const block = blockById.get(e.timeBlockId); + const parts: string[] = []; + if (block?.startDate) { + parts.push( + formatDateTime(new Date(block.startDate), { + day: '2-digit', + month: 'long', + year: 'numeric', + hour: block.allDay ? undefined : '2-digit', + minute: block.allDay ? undefined : '2-digit', + }) + ); + } + if (e.location) parts.push(e.location); + return { + title: e.title, + subtitle: parts.length > 0 ? parts.join(' · ') : undefined, + imageUrl: e.coverImage ?? undefined, + }; + }); +} diff --git a/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte b/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte index 507255a5b..af1849a7e 100644 --- a/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte +++ b/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte @@ -18,6 +18,16 @@ > + + + + + + + + + + diff --git a/packages/website-blocks/src/moduleEmbed/schema.ts b/packages/website-blocks/src/moduleEmbed/schema.ts index a34661bf1..551dcde93 100644 --- a/packages/website-blocks/src/moduleEmbed/schema.ts +++ b/packages/website-blocks/src/moduleEmbed/schema.ts @@ -36,6 +36,9 @@ export const EmbedSourceSchema = z.enum([ 'recipes.recipes', 'wardrobe.outfits', 'comic.stories', + 'habits.habits', + 'quiz.quizzes', + 'events.socialEvents', ]); export type EmbedSource = z.infer;