feat: migrate to Next.js for Mini App and enhance project structure
- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability. - Updated the `.gitignore` to exclude Next.js build artifacts and node modules. - Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack. - Enhanced Dockerfile to support the new build process for the Next.js application. - Updated CI workflow to build and test the Next.js application. - Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking. - Refactored frontend testing setup to accommodate the new structure and testing framework. - Removed legacy webapp files and dependencies to streamline the project.
This commit is contained in:
256
webapp-next/src/components/duty/DutyList.tsx
Normal file
256
webapp-next/src/components/duty/DutyList.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Duty timeline list for the current month: dates, track line, flip cards.
|
||||
* Scrolls to current duty or today on mount. Ported from webapp/js/dutyList.js renderDutyList.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import {
|
||||
localDateString,
|
||||
firstDayOfMonth,
|
||||
lastDayOfMonth,
|
||||
dateKeyToDDMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DutyTimelineCard } from "./DutyTimelineCard";
|
||||
|
||||
/** Extra offset so the sticky calendar slightly overlaps the target card (card sits a bit under the calendar). */
|
||||
const SCROLL_OVERLAP_PX = 14;
|
||||
|
||||
/**
|
||||
* Skeleton placeholder for duty list (e.g. when loading a new month).
|
||||
* Shows 4 card-shaped placeholders in timeline layout.
|
||||
*/
|
||||
export function DutyListSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("duty-timeline relative text-[0.9rem]", className)}>
|
||||
<div
|
||||
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
|
||||
aria-hidden
|
||||
/>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="h-14 w-12 rounded" />
|
||||
<span className="min-w-0" aria-hidden />
|
||||
<Skeleton className="h-20 w-full min-w-0 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DutyListProps {
|
||||
/** Offset from viewport top for scroll target (sticky calendar height + its padding, e.g. 268px). */
|
||||
scrollMarginTop?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders duty timeline (duty type only), grouped by date. Shows "Today" label and
|
||||
* auto-scrolls to current duty or today block. Uses CSS variables --timeline-date-width, --timeline-track-width.
|
||||
*/
|
||||
export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
|
||||
const { t } = useTranslation();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const { currentMonth, duties } = useAppStore(
|
||||
useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties }))
|
||||
);
|
||||
|
||||
const { filtered, dates, dutiesByDateKey } = useMemo(() => {
|
||||
const filteredList = duties.filter((d) => d.event_type === "duty");
|
||||
const todayKey = localDateString(new Date());
|
||||
const firstKey = localDateString(firstDayOfMonth(currentMonth));
|
||||
const lastKey = localDateString(lastDayOfMonth(currentMonth));
|
||||
const showTodayInMonth = todayKey >= firstKey && todayKey <= lastKey;
|
||||
|
||||
const dateSet = new Set<string>();
|
||||
filteredList.forEach((d) =>
|
||||
dateSet.add(localDateString(new Date(d.start_at)))
|
||||
);
|
||||
if (showTodayInMonth) dateSet.add(todayKey);
|
||||
const datesList = Array.from(dateSet).sort();
|
||||
|
||||
const byDate: Record<string, typeof filteredList> = {};
|
||||
datesList.forEach((date) => {
|
||||
byDate[date] = filteredList
|
||||
.filter((d) => localDateString(new Date(d.start_at)) === date)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
filtered: filteredList,
|
||||
dates: datesList,
|
||||
dutiesByDateKey: byDate,
|
||||
};
|
||||
}, [currentMonth, duties]);
|
||||
|
||||
const todayKey = localDateString(new Date());
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;
|
||||
const scrolledForMonthRef = useRef<string | null>(null);
|
||||
const prevScrollMarginTopRef = useRef<number>(scrollMarginTop);
|
||||
const prevMonthKeyRef = useRef<string>(monthKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollMarginTop !== prevScrollMarginTopRef.current) {
|
||||
scrolledForMonthRef.current = null;
|
||||
prevScrollMarginTopRef.current = scrollMarginTop;
|
||||
}
|
||||
if (prevMonthKeyRef.current !== monthKey) {
|
||||
scrolledForMonthRef.current = null;
|
||||
prevMonthKeyRef.current = monthKey;
|
||||
}
|
||||
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const currentCard = el.querySelector<HTMLElement>("[data-current-duty]");
|
||||
const todayBlock = el.querySelector<HTMLElement>("[data-today-block]");
|
||||
const target = currentCard ?? todayBlock;
|
||||
if (!target || scrolledForMonthRef.current === monthKey) return;
|
||||
|
||||
const effectiveMargin = Math.max(0, scrollMarginTop + SCROLL_OVERLAP_PX);
|
||||
const scrollTo = () => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const scrollTop = window.scrollY + rect.top - effectiveMargin;
|
||||
window.scrollTo({ top: scrollTop, behavior: "smooth" });
|
||||
scrolledForMonthRef.current = monthKey;
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(scrollTo);
|
||||
});
|
||||
}, [filtered, dates.length, scrollMarginTop, monthKey]);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<p className="text-sm text-muted m-0">{t("duty.none_this_month")}</p>
|
||||
<p className="text-xs text-muted m-0">{t("duty.none_this_month_hint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={listRef} className={className}>
|
||||
<div className="duty-timeline relative text-[0.9rem]">
|
||||
{/* Vertical track line */}
|
||||
<div
|
||||
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
|
||||
aria-hidden
|
||||
/>
|
||||
{dates.map((date) => {
|
||||
const isToday = date === todayKey;
|
||||
const dateLabel = dateKeyToDDMM(date);
|
||||
const dayDuties = dutiesByDateKey[date] ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={date}
|
||||
className="mb-0"
|
||||
style={isToday ? { scrollMarginTop: `${scrollMarginTop}px` } : undefined}
|
||||
data-date={date}
|
||||
data-today-block={isToday ? true : undefined}
|
||||
>
|
||||
{dayDuties.length > 0 ? (
|
||||
dayDuties.map((duty) => {
|
||||
const start = new Date(duty.start_at);
|
||||
const end = new Date(duty.end_at);
|
||||
const isCurrent = start <= now && now < end;
|
||||
return (
|
||||
<div
|
||||
key={duty.id}
|
||||
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||
}}
|
||||
>
|
||||
<TimelineDateCell
|
||||
dateLabel={dateLabel}
|
||||
isToday={isToday}
|
||||
/>
|
||||
<span
|
||||
className="min-w-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 overflow-hidden"
|
||||
{...(isCurrent ? { "data-current-duty": true } : {})}
|
||||
>
|
||||
<DutyTimelineCard duty={duty} isCurrent={isCurrent} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||
}}
|
||||
>
|
||||
<TimelineDateCell
|
||||
dateLabel={dateLabel}
|
||||
isToday={isToday}
|
||||
/>
|
||||
<span className="min-w-0" aria-hidden />
|
||||
<div className="min-w-0" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineDateCell({
|
||||
dateLabel,
|
||||
isToday,
|
||||
}: {
|
||||
dateLabel: string;
|
||||
isToday: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"duty-timeline-date relative text-[0.8rem] text-muted pt-2.5 pb-2.5 flex-shrink-0 overflow-visible",
|
||||
isToday && "duty-timeline-date--today flex flex-col items-start pt-1 text-today font-semibold"
|
||||
)}
|
||||
>
|
||||
{isToday ? (
|
||||
<>
|
||||
<span className="duty-timeline-date-label text-today block leading-tight">
|
||||
{t("duty.today")}
|
||||
</span>
|
||||
<span className="duty-timeline-date-day text-muted font-normal text-[0.75rem] block self-start text-left">
|
||||
{dateLabel}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
dateLabel
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user