calendar: keep zoom ladder inside month blocks on single scene

This commit is contained in:
Ruslan Bakiev
2026-02-23 10:57:51 +07:00
parent f67cef22be
commit 2b5aab1210

View File

@@ -5199,151 +5199,147 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
:style="calendarSceneTransformStyle"
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''; clearCalendarZoomPrime()"
>
<div v-if="calendarView === 'month'" class="space-y-1">
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
<span>Sun</span>
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
</div>
<div class="space-y-1">
<div
v-for="row in monthRows"
:key="row.key"
class="group relative calendar-hover-targetable"
:class="[
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeWeekToken(row.startKey))"
:data-calendar-week-start-key="row.startKey"
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
>
<div class="grid grid-cols-7 gap-1">
<button
v-for="cell in row.cells"
:key="cell.key"
class="group relative min-h-24 rounded-lg border p-1 text-left"
:class="[
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '',
]"
:data-calendar-day-key="cell.key"
@mouseenter="calendarHoveredDayKey = cell.key"
@click="pickDate(cell.key)"
>
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
<button
v-for="event in monthCellEvents(cell.events)"
:key="event.id"
class="block w-full truncate rounded px-1 text-left text-[10px] text-base-content/70 transition hover:underline"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 text-success-content ring-1 ring-success/40' : ''"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} {{ event.title }}
</button>
</button>
</div>
</div>
</div>
</div>
<div v-else-if="calendarView === 'week'" class="calendar-week-scroll overflow-x-auto pb-1">
<div class="calendar-week-grid">
<article
v-for="day in weekDays"
:key="day.key"
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in calendar-hover-targetable"
<div class="grid gap-2" :class="calendarView === 'year' ? 'sm:grid-cols-2 xl:grid-cols-3' : 'grid-cols-1'">
<article
v-for="item in yearMonths"
:key="`year-month-${item.monthIndex}`"
v-show="calendarView === 'year' || item.monthIndex === calendarCursor.getMonth()"
class="group relative rounded-xl border border-base-300 p-3 text-left transition calendar-hover-targetable"
:class="[
selectedDateKey === day.key ? 'border-primary bg-primary/5' : '',
calendarHoveredDayKey === day.key ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeDayToken(day.key) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeDayToken(day.key))"
:data-calendar-day-key="day.key"
@mouseenter="calendarHoveredDayKey = day.key"
@click="pickDate(day.key)"
>
<div class="mb-2 flex items-start justify-between gap-2">
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
</div>
<div class="space-y-1.5">
<button
v-for="event in day.events"
:key="event.id"
class="block w-full rounded-md px-2 py-1.5 text-left text-xs"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
</button>
<p v-if="day.events.length === 0" class="pt-1 text-xs text-base-content/50">No events</p>
</div>
</article>
</div>
</div>
calendarView === 'year' ? 'hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in' : 'cursor-default min-h-[26rem] bg-base-100',
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex))"
:data-calendar-month-index="item.monthIndex"
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
@click="calendarView === 'year' ? zoomToMonth(item.monthIndex) : undefined"
>
<p class="font-medium">{{ item.label }}</p>
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
<button
v-if="calendarView === 'year' && item.first"
class="mt-1 block w-full text-left text-xs text-base-content/70 hover:underline"
@click.stop="openThreadFromCalendarItem(item.first)"
>
{{ formatDay(item.first.start) }} · {{ item.first.title }}
</button>
<div v-else-if="calendarView === 'day'" class="space-y-2">
<button
v-for="event in selectedDayEvents"
:key="event.id"
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
@click="openThreadFromCalendarItem(event)"
>
<p class="font-medium">{{ event.title }}</p>
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
</button>
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
</div>
<div
v-if="item.monthIndex === calendarCursor.getMonth() && (calendarView === 'month' || calendarView === 'agenda')"
class="mt-3 space-y-1"
>
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
<span>Sun</span>
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
</div>
<div v-else-if="calendarView === 'year'" class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
<button
v-for="item in yearMonths"
:key="`year-month-${item.monthIndex}`"
class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in calendar-hover-targetable"
:class="[
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex))"
:data-calendar-month-index="item.monthIndex"
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
@click="zoomToMonth(item.monthIndex)"
>
<p class="font-medium">{{ item.label }}</p>
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
<button
v-if="item.first"
class="mt-1 block w-full text-left text-xs text-base-content/70 hover:underline"
@click.stop="openThreadFromCalendarItem(item.first)"
>
{{ formatDay(item.first.start) }} · {{ item.first.title }}
</button>
</button>
</div>
<div class="space-y-1">
<div
v-for="row in monthRows"
:key="row.key"
class="group relative calendar-hover-targetable"
:class="[
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeWeekToken(row.startKey))"
:data-calendar-week-start-key="row.startKey"
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
>
<div class="grid grid-cols-7 gap-1">
<button
v-for="cell in row.cells"
:key="cell.key"
class="group relative min-h-24 rounded-lg border p-1 text-left"
:class="[
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '',
]"
:data-calendar-day-key="cell.key"
@mouseenter="calendarHoveredDayKey = cell.key"
@click="pickDate(cell.key)"
>
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
<button
v-for="event in monthCellEvents(cell.events)"
:key="event.id"
class="block w-full truncate rounded px-1 text-left text-[10px] text-base-content/70 transition hover:underline"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 text-success-content ring-1 ring-success/40' : ''"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} {{ event.title }}
</button>
</button>
</div>
</div>
</div>
</div>
<div v-else class="space-y-2">
<button
v-for="event in sortedEvents"
:key="event.id"
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
@click="openThreadFromCalendarItem(event)"
>
<p class="font-medium">{{ event.title }}</p>
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
<p class="text-xs text-base-content/60">{{ formatDay(event.start) }} · {{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
</button>
</div>
<div
v-else-if="item.monthIndex === calendarCursor.getMonth() && calendarView === 'week'"
class="mt-3 calendar-week-scroll overflow-x-auto pb-1"
>
<div class="calendar-week-grid">
<article
v-for="day in weekDays"
:key="day.key"
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in calendar-hover-targetable"
:class="[
selectedDateKey === day.key ? 'border-primary bg-primary/5' : '',
calendarHoveredDayKey === day.key ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeDayToken(day.key) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeDayToken(day.key))"
:data-calendar-day-key="day.key"
@mouseenter="calendarHoveredDayKey = day.key"
@click="pickDate(day.key)"
>
<div class="mb-2 flex items-start justify-between gap-2">
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
</div>
<div class="space-y-1.5">
<button
v-for="event in day.events"
:key="event.id"
class="block w-full rounded-md px-2 py-1.5 text-left text-xs"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
</button>
<p v-if="day.events.length === 0" class="pt-1 text-xs text-base-content/50">No events</p>
</div>
</article>
</div>
</div>
<div
v-else-if="item.monthIndex === calendarCursor.getMonth() && calendarView === 'day'"
class="mt-3 space-y-2"
>
<button
v-for="event in selectedDayEvents"
:key="event.id"
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
@click="openThreadFromCalendarItem(event)"
>
<p class="font-medium">{{ event.title }}</p>
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
</button>
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
</div>
</article>
</div>
</div>
</div>
<div
@@ -5606,31 +5602,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<div class="min-h-0 flex-1 overflow-y-auto p-0">
<div
v-if="peopleListMode === 'contacts' && hiddenContactInboxes.length"
class="border-b border-base-300 px-3 py-2"
>
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wide text-base-content/55">Hidden sources</p>
<div class="space-y-1">
<div
v-for="inbox in hiddenContactInboxes.slice(0, 30)"
:key="`hidden-inbox-${inbox.id}`"
class="flex items-center justify-between gap-2 rounded-lg border border-base-300 bg-base-100 px-2 py-1"
>
<p class="min-w-0 flex-1 truncate text-[10px] text-base-content/75">
{{ inbox.contactName }} · {{ formatInboxLabel(inbox) }}
</p>
<button
class="btn btn-ghost btn-xs h-5 min-h-5 px-1"
:disabled="isInboxToggleLoading(inbox.id)"
@click="setInboxHidden(inbox.id, false)"
>
{{ isInboxToggleLoading(inbox.id) ? "..." : "Show" }}
</button>
</div>
</div>
</div>
<button
v-if="peopleListMode === 'contacts'"
v-for="thread in peopleContactList"
:key="thread.id"
@@ -5640,6 +5611,10 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
isReviewHighlightedContact(thread.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
]"
@click="openCommunicationThread(thread.contact)"
role="button"
tabindex="0"
@keydown.enter.prevent="openCommunicationThread(thread.contact)"
@keydown.space.prevent="openCommunicationThread(thread.contact)"
>
<div class="flex items-start gap-2">
<div class="avatar shrink-0">
@@ -5661,24 +5636,46 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<p class="truncate text-xs font-semibold">{{ thread.contact }}</p>
<span class="shrink-0 text-[10px] text-base-content/55">{{ formatThreadTime(thread.lastAt) }}</span>
</div>
<div class="mt-0.5 flex items-center gap-2">
<p class="min-w-0 flex-1 truncate text-[11px] text-base-content/75">{{ thread.lastText }}</p>
<span
class="inline-block h-2 w-2 rounded-full"
:class="
threadTone(thread) === 'event'
? 'bg-red-500'
: threadTone(thread) === 'recommendation'
? 'bg-violet-500'
: threadTone(thread) === 'message'
? 'bg-blue-500'
: 'bg-base-300'
"
/>
<div class="mt-0.5 flex items-center justify-between gap-2">
<p class="min-w-0 flex-1 truncate text-[11px] text-base-content/75">{{ threadChannelLabel(thread) }}</p>
<div class="dropdown dropdown-end" @click.stop>
<button
tabindex="0"
class="btn btn-ghost btn-xs btn-square h-5 min-h-5"
title="Source visibility settings"
>
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M19.14 12.94a7.43 7.43 0 0 0 .05-.94 7.43 7.43 0 0 0-.05-.94l2.03-1.58a.5.5 0 0 0 .12-.63l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.2 7.2 0 0 0-1.62-.94l-.36-2.54A.5.5 0 0 0 13.9 2h-3.8a.5.5 0 0 0-.49.41L9.25 4.95a7.2 7.2 0 0 0-1.62.94l-2.39-.96a.5.5 0 0 0-.6.22L2.72 8.47a.5.5 0 0 0 .12.63l2.03 1.58a7.43 7.43 0 0 0-.05.94c0 .31.02.63.05.94l-2.03 1.58a.5.5 0 0 0-.12.63l1.92 3.32c.13.23.39.32.6.22l2.39-.96c.5.39 1.05.71 1.62.94l.36 2.54c.04.24.25.41.49.41h3.8c.24 0 .45-.17.49-.41l.36-2.54c.57-.23 1.12-.55 1.62-.94l2.39.96c.22.09.47 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.63zM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5Z" />
</svg>
</button>
<div tabindex="0" class="dropdown-content z-20 mt-1 w-60 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
<p class="px-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-base-content/55">Sources</p>
<div v-if="threadInboxes(thread).length" class="space-y-1">
<button
v-for="inbox in threadInboxes(thread)"
:key="`thread-inbox-setting-${inbox.id}`"
class="btn btn-ghost btn-xs h-auto min-h-0 w-full justify-between px-2 py-1 text-left normal-case"
@click.stop="setInboxHidden(inbox.id, !inbox.isHidden)"
>
<span class="min-w-0 truncate">{{ formatInboxLabel(inbox) }}</span>
<span class="shrink-0 text-[10px] text-base-content/70">
{{
isInboxToggleLoading(inbox.id)
? "..."
: inbox.isHidden
? "Hidden"
: "Visible"
}}
</span>
</button>
</div>
<p v-else class="px-1 py-1 text-[11px] text-base-content/60">No sources.</p>
</div>
</div>
</div>
</div>
</div>
</button>
</div>
<button
v-if="peopleListMode === 'deals'"
@@ -5862,30 +5859,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<span class="shrink-0 text-xs text-base-content/75">{{ selectedCommPinnedStream.length }}</span>
</button>
<div v-if="selectedThreadInboxes.length" class="mb-2 flex flex-wrap items-center gap-1.5">
<div
v-for="inbox in selectedThreadInboxes"
:key="`inbox-chip-${inbox.id}`"
class="inline-flex items-center gap-1 rounded-full border border-base-300 bg-base-100 px-2 py-1 text-[10px]"
:class="inbox.isHidden ? 'opacity-60' : ''"
>
<span class="truncate max-w-[180px]">{{ formatInboxLabel(inbox) }}</span>
<button
class="btn btn-ghost btn-xs h-5 min-h-5 px-1"
:disabled="isInboxToggleLoading(inbox.id)"
@click="setInboxHidden(inbox.id, !inbox.isHidden)"
>
{{
isInboxToggleLoading(inbox.id)
? "..."
: inbox.isHidden
? "Show"
: "Hide"
}}
</button>
</div>
</div>
<div
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
:key="entry.id"