feat(visibility): embed resolvers for habits/quiz/social-events + inspector refresh

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 13:58:43 +02:00
parent 21dbce6631
commit 59b147f5ee
3 changed files with 216 additions and 0 deletions

View file

@ -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<ResolvedEmb
case 'comic.stories':
items = await resolveComicStories(props);
break;
case 'habits.habits':
items = await resolveHabits(props);
break;
case 'quiz.quizzes':
items = await resolveQuizzes(props);
break;
case 'events.socialEvents':
items = await resolveSocialEvents(props);
break;
default:
return {
items: [],
@ -572,3 +584,194 @@ async function resolveComicStories(props: ModuleEmbedProps): Promise<EmbedItem[]
};
});
}
/**
* Habits: build-in-public use case. Returns active habits flipped to
* 'public' with their current streak as subtitle.
*
* Whitelist: title + "🔥 N Tage Streak · gesamt M ×" never the per-log
* timestamps or notes (those reveal sleep/intake patterns). Streak +
* total are aggregate counts that sit at the right level of detail
* for a public "what I'm working on" widget.
*/
async function resolveHabits(props: ModuleEmbedProps): Promise<EmbedItem[]> {
let habits = await db.table<LocalHabit>('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<LocalHabitLog>('habitLogs').toArray();
const logsByHabit = new Map<string, LocalHabitLog[]>();
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<LocalTimeBlock>('timeBlocks').where('id').anyOf(blockIds).toArray()
: [];
const blockDateById = new Map<string, string>();
for (const b of blocks) blockDateById.set(b.id, (b.startDate ?? '').slice(0, 10));
function streakFor(logs: LocalHabitLog[]): number {
const dates = new Set<string>();
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<EmbedItem[]> {
let quizzes = await db.table<LocalQuiz>('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<EmbedItem[]> {
let events = await db.table<LocalSocialEvent>('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<LocalTimeBlock>('timeBlocks').where('id').anyOf(blockIds).toArray()
: [];
const blockById = new Map<string, LocalTimeBlock>();
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,
};
});
}

View file

@ -18,6 +18,16 @@
>
<option value="picture.board">Picture-Board</option>
<option value="library.entries">Bibliothek</option>
<option value="calendar.events">Kalender (Termine)</option>
<option value="todo.tasks">Todos</option>
<option value="goals.goals">Ziele</option>
<option value="places.places">Orte</option>
<option value="recipes.recipes">Rezepte</option>
<option value="wardrobe.outfits">Wardrobe (Outfits)</option>
<option value="comic.stories">Comics</option>
<option value="habits.habits">Habits</option>
<option value="quiz.quizzes">Quizze</option>
<option value="events.socialEvents">Events (RSVP)</option>
</select>
</label>

View file

@ -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<typeof EmbedSourceSchema>;