fiddy/apps/web/features/entries/components/entries-list.tsx
Nico f8e426542d
Some checks failed
Build & Deploy Fiddy (Dokploy) / build (push) Has been cancelled
Build & Deploy Fiddy (Dokploy) / deploy (push) Has been cancelled
feat: implement schedules pivot, scheduler service, and dokploy deploy flow
2026-02-15 17:10:58 -08:00

233 lines
9.5 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import type { Entry } from "@/lib/shared/types";
type EntriesListProps = {
activeGroupId: number | null;
loading: boolean;
entries: Entry[];
visibleEntries: Entry[];
activeFilterCount: number;
onOpenDetails: (entry: Entry, index: number) => void;
onClearFilters: () => void;
};
const TAG_GAP_PX = 8;
const TAG_CLASS = "shrink-0 rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs";
const TAG_MORE_CLASS = "shrink-0 rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft";
function NecessityIcon({ necessity }: { necessity: Entry["necessity"] }) {
if (necessity === "NECESSARY") {
return (
<span
className="flex h-5 w-5 items-center justify-center rounded-full border border-accent-weak bg-accent-soft text-[color:var(--color-accent)]"
title="Necessary"
aria-label="Necessary"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
);
}
if (necessity === "UNNECESSARY") {
return (
<span
className="flex h-5 w-5 items-center justify-center rounded-full border border-red-400/60 bg-red-500/10 text-red-200"
title="Unnecessary"
aria-label="Unnecessary"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m18 6-12 12" />
<path d="m6 6 12 12" />
</svg>
</span>
);
}
return (
<span
className="flex h-5 w-5 items-center justify-center rounded-full border border-amber-400/60 bg-amber-500/10 text-amber-200"
title="Both"
aria-label="Both"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 9h6" />
<path d="M9 15h6" />
<path d="M12 6v6" />
</svg>
</span>
);
}
function EntryTagsRow({ tags }: { tags: string[] }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef<HTMLSpanElement | null>(null);
const tagRefs = useRef<(HTMLSpanElement | null)[]>([]);
const [visibleCount, setVisibleCount] = useState(tags.length);
useEffect(() => {
tagRefs.current = tagRefs.current.slice(0, tags.length);
setVisibleCount(tags.length);
}, [tags]);
useEffect(() => {
if (!tags.length) return;
function recomputeVisibleCount() {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const widths = tags.map((_, index) => tagRefs.current[index]?.offsetWidth ?? 0);
const moreProbe = moreRef.current;
if (!containerWidth || !moreProbe || widths.some(width => !width)) {
setVisibleCount(tags.length);
return;
}
const totalTagsWidth = widths.reduce((sum, width) => sum + width, 0) + TAG_GAP_PX * Math.max(widths.length - 1, 0);
if (totalTagsWidth <= containerWidth) {
setVisibleCount(tags.length);
return;
}
let nextVisibleCount = 0;
let usedWidth = 0;
for (let index = 0; index < widths.length; index += 1) {
usedWidth += index === 0 ? widths[index] : TAG_GAP_PX + widths[index];
const remaining = widths.length - (index + 1);
if (remaining <= 0) {
nextVisibleCount = widths.length;
break;
}
moreProbe.textContent = `${remaining} more...`;
const moreWidth = moreProbe.offsetWidth;
const totalWithMore = usedWidth + TAG_GAP_PX + moreWidth;
if (totalWithMore <= containerWidth) nextVisibleCount = index + 1;
else break;
}
setVisibleCount(nextVisibleCount);
}
recomputeVisibleCount();
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(recomputeVisibleCount);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}
window.addEventListener("resize", recomputeVisibleCount);
return () => window.removeEventListener("resize", recomputeVisibleCount);
}, [tags]);
if (!tags.length) {
return <span className="rounded-full border border-accent-weak px-2 py-0.5 text-xs text-soft">No tags</span>;
}
const visibleTags = tags.slice(0, visibleCount);
const hasOverflow = visibleCount < tags.length;
const remainingCount = tags.length - visibleCount;
return (
<div className="relative w-full">
<div ref={containerRef} className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
{visibleTags.map((tag, index) => (
<span key={`${tag}-${index}`} className={TAG_CLASS}>
#{tag}
</span>
))}
{hasOverflow ? <span className={TAG_MORE_CLASS}>{remainingCount} more...</span> : null}
</div>
<div className="pointer-events-none absolute left-0 top-0 -z-10 opacity-0" aria-hidden="true">
{tags.map((tag, index) => (
<span
key={`${tag}-${index}`}
ref={element => {
tagRefs.current[index] = element;
}}
className={`${TAG_CLASS} inline-block`}
>
#{tag}
</span>
))}
<span ref={moreRef} className={`${TAG_MORE_CLASS} inline-block`}>
{tags.length} more...
</span>
</div>
</div>
);
}
export default function EntriesList({
activeGroupId,
loading,
entries,
visibleEntries,
activeFilterCount,
onOpenDetails,
onClearFilters
}: EntriesListProps) {
return (
<div className="mt-3 space-y-2">
{!activeGroupId ? (
<div className="text-sm text-muted">Select a group to view entries.</div>
) : loading ? (
<div className="space-y-2">
{[0, 1, 2].map(row => (
<div key={row} className="rounded-lg border border-accent-weak bg-panel px-3 py-3">
<div className="animate-pulse space-y-2">
<div className="h-4 w-28 rounded bg-surface" />
<div className="h-3 w-40 rounded bg-surface" />
<div className="flex flex-wrap gap-2">
<div className="h-5 w-14 rounded-full bg-surface" />
<div className="h-5 w-12 rounded-full bg-surface" />
<div className="h-5 w-16 rounded-full bg-surface" />
</div>
</div>
</div>
))}
</div>
) : entries.length ? (
visibleEntries.length ? (
visibleEntries.map((entry, index) => {
const tags = entry.tags ?? [];
return (
<div
key={entry.id}
className="flex min-h-[84px] cursor-pointer flex-col justify-between gap-2 rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm transition hover:border-accent hover:bg-accent-soft"
onClick={() => onOpenDetails(entry, index)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<NecessityIcon necessity={entry.necessity} />
<div className="text-base font-semibold">${entry.amountDollars.toFixed(2)}</div>
</div>
<div className="text-xs text-muted">{new Date(entry.occurredAt).toISOString().slice(0, 10)}</div>
</div>
<EntryTagsRow tags={tags} />
</div>
);
})
) : (
<div className="space-y-2 text-sm text-muted">
<div>No matching entries.</div>
{activeFilterCount ? (
<button type="button" className="rounded-lg btn-outline-accent px-3 py-1 text-xs" onClick={onClearFilters}>
Clear filters
</button>
) : null}
</div>
)
) : (
<div className="text-sm text-muted">No entries yet.</div>
)}
</div>
);
}