feat(timeblocks): integrate music, moodlit, presi modules

Extend the unified TimeBlock system to 3 more modules.

New TimeBlockTypes: listening, mood, rehearsal
New SourceModules: music, moodlit, presi

- music: incrementPlayCount() creates 'listening' block with song title,
  artist, and duration-based endDate
- moodlit: add startMoodSession/endMoodSession with live 'mood' blocks
  using the mood's primary color
- presi: add startRehearsal/endRehearsal with live 'rehearsal' blocks
  for presentation practice sessions
- Update analytics colors/labels, calendar filters, dashboard widgets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 19:19:54 +02:00
parent e068335dd4
commit cbfe995f7b
10 changed files with 140 additions and 7 deletions

View file

@ -29,6 +29,9 @@
Compass,
MapPin,
BookOpen,
MusicNote,
SunHorizon,
Presentation,
} from '@mana/shared-icons';
import { getIconComponent } from '@mana/shared-icons';
import { formatDistanceToNow } from 'date-fns';
@ -63,6 +66,9 @@
guide: Compass,
visit: MapPin,
study: BookOpen,
listening: MusicNote,
mood: SunHorizon,
rehearsal: Presentation,
};
function timeAgo(iso: string): string {

View file

@ -28,6 +28,9 @@
Compass,
MapPin,
BookOpen,
MusicNote,
SunHorizon,
Presentation,
} from '@mana/shared-icons';
import { getIconComponent } from '@mana/shared-icons';
import { format } from 'date-fns';
@ -78,6 +81,9 @@
guide: { icon: Compass, label: 'Guide' },
visit: { icon: MapPin, label: 'Besuch' },
study: { icon: BookOpen, label: 'Lernen' },
listening: { icon: MusicNote, label: 'Musik' },
mood: { icon: SunHorizon, label: 'Stimmung' },
rehearsal: { icon: Presentation, label: 'Probe' },
};
function formatBlockTime(block: TimeBlock): string {

View file

@ -33,6 +33,9 @@ const TYPE_COLORS: Record<TimeBlockType, string> = {
guide: '#14b8a6',
visit: '#a855f7',
study: '#0ea5e9',
listening: '#d946ef',
mood: '#fb923c',
rehearsal: '#84cc16',
};
const TYPE_LABELS: Record<TimeBlockType, string> = {
@ -50,6 +53,9 @@ const TYPE_LABELS: Record<TimeBlockType, string> = {
guide: 'Guides',
visit: 'Besuche',
study: 'Lernen',
listening: 'Musik',
mood: 'Stimmung',
rehearsal: 'Probe',
};
function blockDuration(b: TimeBlock): number {

View file

@ -26,7 +26,10 @@ export type TimeBlockType =
| 'cycle'
| 'guide'
| 'visit'
| 'study';
| 'study'
| 'listening'
| 'mood'
| 'rehearsal';
export type TimeBlockSourceModule =
| 'calendar'
@ -41,7 +44,10 @@ export type TimeBlockSourceModule =
| 'cycles'
| 'guides'
| 'places'
| 'cards';
| 'cards'
| 'music'
| 'moodlit'
| 'presi';
// ─── Local Record Types (Dexie) ──────────────────────────

View file

@ -20,6 +20,9 @@
Compass,
MapPin,
BookOpen,
MusicNote,
SunHorizon,
Presentation,
} from '@mana/shared-icons';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
@ -50,6 +53,9 @@
{ type: 'guide', label: 'Guides', icon: Compass },
{ type: 'visit', label: 'Besuche', icon: MapPin },
{ type: 'study', label: 'Lernen', icon: BookOpen },
{ type: 'listening', label: 'Musik', icon: MusicNote },
{ type: 'mood', label: 'Stimmung', icon: SunHorizon },
{ type: 'rehearsal', label: 'Probe', icon: Presentation },
];
let allActive = $derived(

View file

@ -39,6 +39,9 @@ let visibleBlockTypes = $state<Set<TimeBlockType>>(
'guide',
'visit',
'study',
'listening',
'mood',
'rehearsal',
])
);

View file

@ -4,6 +4,7 @@
import { db } from '$lib/data/database';
import { MoodlitEvents } from '@mana/shared-utils/analytics';
import { createBlock, updateBlock } from '$lib/data/time-blocks/service';
import type { LocalMood } from '../types';
import type { Mood, MoodSettings } from '../types';
@ -21,6 +22,7 @@ function createMoodsStore() {
let favoriteIds = $state<string[]>([]);
let settings = $state<MoodSettings>({ ...DEFAULT_SETTINGS });
let activeMood = $state<Mood | null>(null);
let activeSessionBlockId = $state<string | null>(null);
// Load from localStorage on init
if (typeof window !== 'undefined') {
@ -65,6 +67,34 @@ function createMoodsStore() {
activeMood = mood;
},
async startMoodSession(mood: Mood): Promise<void> {
if (activeSessionBlockId) {
await updateBlock(activeSessionBlockId, {
endDate: new Date().toISOString(),
});
}
activeSessionBlockId = await createBlock({
startDate: new Date().toISOString(),
endDate: null,
isLive: true,
kind: 'logged',
type: 'mood',
sourceModule: 'moodlit',
sourceId: mood.id,
title: mood.name,
color: mood.colors?.[0] ?? '#fb923c',
});
},
async endMoodSession(): Promise<void> {
if (!activeSessionBlockId) return;
await updateBlock(activeSessionBlockId, {
endDate: new Date().toISOString(),
isLive: false,
});
activeSessionBlockId = null;
},
addMood(mood: Mood) {
customMoods = [...customMoods, mood];
persist();

View file

@ -6,7 +6,8 @@
*/
import { songTable } from '../collections';
import { encryptRecord } from '$lib/data/crypto';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { createBlock } from '$lib/data/time-blocks/service';
import { MusicEvents } from '@mana/shared-utils/analytics';
import type { LocalSong } from '../types';
@ -24,15 +25,35 @@ export const libraryStore = {
}
},
/** Increment play count. */
/** Increment play count and create a listening TimeBlock. */
async incrementPlayCount(id: string) {
const local = await songTable.get(id);
if (local) {
const now = new Date().toISOString();
await songTable.update(id, {
playCount: (local.playCount || 0) + 1,
lastPlayedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastPlayedAt: now,
updatedAt: now,
});
const decrypted = await decryptRecord('songs', { ...local });
const title = decrypted?.title ?? 'Song';
const artist = decrypted?.artist;
const endDate = local.duration
? new Date(Date.now() + local.duration * 1000).toISOString()
: now;
await createBlock({
startDate: now,
endDate,
kind: 'logged',
type: 'listening',
sourceModule: 'music',
sourceId: id,
title: artist ? `${title}${artist}` : title,
color: '#d946ef',
});
MusicEvents.songPlayed();
}
},

View file

@ -9,7 +9,8 @@ import { db } from '$lib/data/database';
import { presiDeckTable, slideTable } from '../collections';
import { toDeck, toSlide } from '../queries';
import { PresiEvents } from '@mana/shared-utils/analytics';
import { encryptRecord } from '$lib/data/crypto';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { createBlock, updateBlock } from '$lib/data/time-blocks/service';
import type {
LocalDeck,
LocalSlide,
@ -164,6 +165,51 @@ function createDecksStore() {
}
}
async function startRehearsal(deckId: string): Promise<string | null> {
const deck = await presiDeckTable.get(deckId);
if (!deck) return null;
if (deck.activeRehearsalBlockId) return deck.activeRehearsalBlockId;
const decrypted = await decryptRecord('presiDecks', { ...deck });
const deckTitle = decrypted?.title ?? 'Präsentation';
const now = new Date().toISOString();
const timeBlockId = await createBlock({
startDate: now,
endDate: null,
isLive: true,
kind: 'logged',
type: 'rehearsal',
sourceModule: 'presi',
sourceId: deckId,
title: `${deckTitle} — Probe`,
color: '#84cc16',
});
await presiDeckTable.update(deckId, {
activeRehearsalBlockId: timeBlockId,
updatedAt: now,
});
return timeBlockId;
}
async function endRehearsal(deckId: string): Promise<void> {
const deck = await presiDeckTable.get(deckId);
if (!deck?.activeRehearsalBlockId) return;
const now = new Date().toISOString();
await updateBlock(deck.activeRehearsalBlockId, {
endDate: now,
isLive: false,
});
await presiDeckTable.update(deckId, {
activeRehearsalBlockId: null,
updatedAt: now,
});
}
return {
get isLoading() {
return isLoading;
@ -178,6 +224,8 @@ function createDecksStore() {
updateSlide,
deleteSlide,
reorderSlides,
startRehearsal,
endRehearsal,
};
}

View file

@ -9,6 +9,7 @@ export interface LocalDeck extends BaseRecord {
description?: string | null;
themeId?: string | null;
isPublic: boolean;
activeRehearsalBlockId?: string | null;
}
export interface LocalSlide extends BaseRecord {