diff --git a/package.json b/package.json index 50efdd5f..9f8ceb95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.95.4", + "version": "1.96.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/tasks/StickerCalendar.svelte b/src/lib/components/tasks/StickerCalendar.svelte index cfb0f736..634f5e19 100644 --- a/src/lib/components/tasks/StickerCalendar.svelte +++ b/src/lib/components/tasks/StickerCalendar.svelte @@ -4,25 +4,50 @@ import { getStickerById } from '$lib/utils/stickers'; import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, - eachDayOfInterval, isSameMonth, isToday, format, addMonths, subMonths + eachDayOfInterval, isSameMonth, isToday, isWeekend, format, addMonths, subMonths } from 'date-fns'; import { de } from 'date-fns/locale'; let { completions = [], currentUser = '' } = $props(); - let viewDate = $state(new Date()); + // who-did-what colours (the household) + const PERSON_COLOR = /** @type {Record} */ ({ + anna: 'var(--nord15)', + alexander: 'var(--nord10)' + }); + const personColor = /** @param {string} who */ (who) => PERSON_COLOR[who?.toLowerCase()] || 'var(--nord12)'; - let filteredCompletions = $derived( - completions - .filter((/** @type {any} */ c) => c.stickerId) - .filter((/** @type {any} */ c) => !currentUser || c.completedBy === currentUser) - ); + // every sticker drop, both members + let drops = $derived(completions.filter((/** @type {any} */ c) => c.stickerId)); - // Build a map: "YYYY-MM-DD" -> sticker ids[] - let stickersByDate = $derived.by(() => { + // Who's visible on the grid. Default: just the current user; others appear + // only when you tap their name in the tally. + let allPeople = $derived([...new Set(drops.map((/** @type {any} */ c) => c.completedBy))]); + let defaultShown = $derived(new Set(currentUser ? [currentUser] : allPeople)); + /** @type {Set | null} */ + let manual = $state(null); + let shown = $derived(manual ?? defaultShown); + /** @param {string} who */ + function toggle(who) { + const next = new Set(shown); + if (next.has(who)) next.delete(who); + else next.add(who); + manual = next; + } + + /** @param {string} s */ + function hash(s) { + let h = 0; + for (let i = 0; i < (s || '').length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0; + return Math.abs(h); + } + + // "YYYY-MM-DD" -> completions[] + let byDate = $derived.by(() => { /** @type {Map} */ const map = new Map(); - for (const c of filteredCompletions) { + for (const c of drops) { + if (!shown.has(c.completedBy)) continue; const key = format(new Date(c.completedAt), 'yyyy-MM-dd'); if (!map.has(key)) map.set(key, []); map.get(key)?.push(c); @@ -31,59 +56,89 @@ }); let calendarDays = $derived.by(() => { - const monthStart = startOfMonth(viewDate); - const monthEnd = endOfMonth(viewDate); - const calStart = startOfWeek(monthStart, { locale: de }); - const calEnd = endOfWeek(monthEnd, { locale: de }); + const calStart = startOfWeek(startOfMonth(viewDate), { locale: de }); + const calEnd = endOfWeek(endOfMonth(viewDate), { locale: de }); return eachDayOfInterval({ start: calStart, end: calEnd }); }); + let viewDate = $state(new Date()); let monthLabel = $derived(format(viewDate, 'MMMM yyyy', { locale: de })); - const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + // per-person tally for the visible month + let tally = $derived.by(() => { + /** @type {Map} */ + const m = new Map(); + for (const c of drops) { + if (!isSameMonth(new Date(c.completedAt), viewDate)) continue; + m.set(c.completedBy, (m.get(c.completedBy) || 0) + 1); + } + return [...m.entries()].sort((a, b) => b[1] - a[1]); + }); + const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; function prevMonth() { viewDate = subMonths(viewDate, 1); } function nextMonth() { viewDate = addMonths(viewDate, 1); } -
+
+ + +
- + {monthLabel} - +
+ {#if tally.length > 0} +
+ {#each tally as [who, n] (who)} + + {/each} +
+ {/if} +
- {#each weekdays as day} + {#each weekdays as day (day)}
{day}
{/each} - {#each calendarDays as day} + {#each calendarDays as day (day.toISOString())} {@const key = format(day, 'yyyy-MM-dd')} - {@const dayStickers = stickersByDate.get(key) || []} + {@const dayDrops = byDate.get(key) || []} {@const inMonth = isSameMonth(day, viewDate)}
0} > {format(day, 'd')} - {#if dayStickers.length > 0} -
- {#each dayStickers.slice(0, 6) as completion} - {@const sticker = getStickerById(completion.stickerId)} + {#if dayDrops.length > 0} +
+ {#each dayDrops.slice(0, 4) as c (c._id)} + {@const sticker = getStickerById(c.stickerId)} {#if sticker} - {sticker.name} + {@const tilt = (hash(c._id) % 13) - 6} + + {sticker.name} + + {/if} {/each} - {#if dayStickers.length > 6} - +{dayStickers.length - 6} + {#if dayDrops.length > 4} + +{dayDrops.length - 4} {/if}
{/if} @@ -93,27 +148,48 @@
diff --git a/src/lib/components/tasks/VinylSticker.svelte b/src/lib/components/tasks/VinylSticker.svelte new file mode 100644 index 00000000..df442277 --- /dev/null +++ b/src/lib/components/tasks/VinylSticker.svelte @@ -0,0 +1,208 @@ + + + +
owned && onpick?.(sticker)} + onkeydown={(e) => owned && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onpick?.(sticker))} + style="--tilt: {tilt}deg; --mx: {mx}%; --my: {my}%; --m: url('/stickers/{sticker.image}'); --foil: {owned ? foilByRarity[sticker.rarity] : 0}; --on: {active ? 1 : 0}; --rarity: {getRarityColor(sticker.rarity)};" + title={owned ? `${sticker.name} — ${rarityLabels[sticker.rarity]}` : 'Noch nicht gesammelt'} +> + {#if owned} +
+ + {sticker.name} + + + {#if count > 1}×{count}{/if} +
+ {sticker.name} + {:else} + + ? + {/if} +
+ + diff --git a/src/lib/components/tasks/VinylStickerCard.svelte b/src/lib/components/tasks/VinylStickerCard.svelte new file mode 100644 index 00000000..fde20a72 --- /dev/null +++ b/src/lib/components/tasks/VinylStickerCard.svelte @@ -0,0 +1,181 @@ + + + + +
e.key === 'Escape' && onclose?.()}> +
e.stopPropagation()} + > +
+
+ {sticker.name} + +
+
+ +

{sticker.name}

+ {rarityLabels[sticker.rarity]} +

{sticker.description}

+ +
+
Anzahl
×{count}
+
Zuerst erhalten
{firstEarnedLabel || '—'}
+
Quelle
{sourceTask || '—'}
+
+ + +
+
+ + diff --git a/src/lib/utils/stickers.ts b/src/lib/utils/stickers.ts index 9eee4234..c035e2d7 100644 --- a/src/lib/utils/stickers.ts +++ b/src/lib/utils/stickers.ts @@ -172,6 +172,18 @@ const DIFFICULTY_RARITY_WEIGHTS: Record> = { high: { common: 25, uncommon: 30, rare: 30, legendary: 15 }, }; +// Categories that can drop from ANY task (see getStickerForTags below). +export const ALWAYS_CATEGORIES = ['general', 'achievement', 'cozy', 'special']; + +// Reverse of TAG_CATEGORY_MAP: which task tags can drop a given category. +export function getTagsForCategory(category: string): string[] { + const tags: string[] = []; + for (const [tag, cats] of Object.entries(TAG_CATEGORY_MAP)) { + if (cats.includes(category)) tags.push(tag); + } + return tags; +} + export function getStickerForTags(tags: string[], difficulty?: string): Sticker { const weights = DIFFICULTY_RARITY_WEIGHTS[difficulty || 'medium'] || DIFFICULTY_RARITY_WEIGHTS.medium; diff --git a/src/routes/tasks/rewards/+page.svelte b/src/routes/tasks/rewards/+page.svelte index 3ee187b1..c069989a 100644 --- a/src/routes/tasks/rewards/+page.svelte +++ b/src/routes/tasks/rewards/+page.svelte @@ -1,65 +1,100 @@