feat(calendar): header continuity with week numbers + skeleton content in fly-rect

- Add ISO week numbers to the left of week rows in month view (8, 9, 10...)
  with spacer alignment on day-of-week headers
- Inject label + skeleton placeholder lines into fly-rect during zoom animations:
  zoom-in shows source label (month name / "Week N" / day name) + pulsing bars
  zoom-out shows target context label + skeleton
- Skeleton CSS uses pulse animation (0.8s alternate) for loading hint
- Non-scoped style block for dynamically injected innerHTML elements
- isoWeekNumber helper for ISO 8601 week calculation
- Extended MonthRow type with weekNumber property

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-02-24 14:28:31 +07:00
parent 77141978c5
commit 9505cecab2
2 changed files with 131 additions and 15 deletions

View File

@@ -2672,6 +2672,14 @@ function weekRowStartForDate(key: string) {
return dayKey(date);
}
function isoWeekNumber(dateString: string): number {
const d = new Date(`${dateString}T00:00:00`);
const t = new Date(d.getTime());
t.setDate(t.getDate() + 3 - ((t.getDay() + 6) % 7));
const y = new Date(t.getFullYear(), 0, 4);
return 1 + Math.round(((t.getTime() - y.getTime()) / 86400000 - 3 + ((y.getDay() + 6) % 7)) / 7);
}
function nextAnimationFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
@@ -2695,6 +2703,48 @@ function resetFlyRectStyle(flyEl: HTMLElement) {
flyEl.style.backgroundColor = "";
flyEl.style.borderRadius = "";
flyEl.style.boxShadow = "";
flyEl.innerHTML = "";
}
function extractSourceLabel(sourceElement: HTMLElement, viewBefore: string): string {
if (viewBefore === "year") {
const p = sourceElement.querySelector("p");
return p?.textContent?.trim() ?? "";
}
if (viewBefore === "month" || viewBefore === "agenda") {
const wn = sourceElement.querySelector(".calendar-week-number");
return wn ? `Week ${wn.textContent?.trim()}` : "";
}
if (viewBefore === "week") {
const p = sourceElement.querySelector("p");
return p?.textContent?.trim() ?? "";
}
return "";
}
function buildFlyRectContent(labelText: string): string {
const skeleton = `
<div class="calendar-fly-skeleton">
<div class="calendar-fly-skeleton-line" style="width:70%"></div>
<div class="calendar-fly-skeleton-line" style="width:45%"></div>
<div class="calendar-fly-skeleton-line" style="width:60%"></div>
</div>
`;
return `<div class="calendar-fly-content"><p class="calendar-fly-label">${labelText}</p>${skeleton}</div>`;
}
function buildFlyRectContentForZoomOut(currentView: string): string {
let labelText = "";
if (currentView === "day") {
const d = new Date(`${selectedDateKey.value}T00:00:00`);
labelText = new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(d) + " " + d.getDate();
} else if (currentView === "week") {
const weekStart = weekRowStartForDate(selectedDateKey.value);
labelText = `Week ${isoWeekNumber(weekStart)}`;
} else if (currentView === "month" || currentView === "agenda") {
labelText = new Intl.DateTimeFormat("en-US", { month: "long" }).format(calendarCursor.value);
}
return buildFlyRectContent(labelText);
}
async function animateCalendarFlipTransition(
@@ -2740,6 +2790,8 @@ async function animateCalendarFlipTransition(
flyEl.style.borderColor = "color-mix(in oklab, var(--color-base-300) 100%, transparent)";
flyEl.style.backgroundColor = "color-mix(in oklab, var(--color-base-100) 100%, transparent)";
flyEl.style.boxShadow = "";
// Inject label + skeleton for zoom-out
flyEl.innerHTML = buildFlyRectContentForZoomOut(calendarView.value);
calendarFlyVisible.value = true;
// 3. Switch to parent view
@@ -2815,18 +2867,22 @@ async function animateCalendarZoomIntoSource(
return;
}
// 1. Fade out siblings
// 1. Extract label before fading children
const labelText = extractSourceLabel(sourceElement, calendarView.value);
// 2. Fade out siblings
const siblings = Array.from(
sceneEl?.querySelectorAll<HTMLElement>(".calendar-hover-targetable") ?? [],
).filter((el) => el !== sourceElement && !sourceElement.contains(el) && !el.contains(sourceElement));
await calendarTweenTo(siblings, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
// 2. Fade out source element's inner content (keep border/bg visible)
// 3. Fade out source element's inner content (keep border/bg visible)
const sourceChildren = Array.from(sourceElement.children) as HTMLElement[];
await calendarTweenTo(sourceChildren, { opacity: 0, duration: 0.12, ease: "power2.in" });
// 3. Clone source visual style to fly-rect, position at source bounds
// 4. Clone source visual style to fly-rect, inject label + skeleton
cloneElementStyleToFlyRect(sourceElement, flyEl);
flyEl.innerHTML = buildFlyRectContent(labelText);
const srcLeft = sourceRect.left - wrapRect.left;
const srcTop = sourceRect.top - wrapRect.top;
gsap.set(flyEl, {
@@ -3067,13 +3123,15 @@ const monthCells = computed(() => {
});
const monthRows = computed(() => {
const rows: Array<{ key: string; startKey: string; cells: typeof monthCells.value }> = [];
const rows: Array<{ key: string; startKey: string; weekNumber: number; cells: typeof monthCells.value }> = [];
for (let index = 0; index < monthCells.value.length; index += 7) {
const cells = monthCells.value.slice(index, index + 7);
if (!cells.length) continue;
const startKey = cells[0]?.key ?? selectedDateKey.value;
rows.push({
key: `${cells[0]?.key ?? index}-week-row`,
startKey: cells[0]?.key ?? selectedDateKey.value,
startKey,
weekNumber: isoWeekNumber(startKey),
cells,
});
}