From ec2417fc3e7029cb6fa84aa184daac2768ddad85 Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@mosquit.one>
Date: Sun, 10 May 2026 22:46:59 +0900
Subject: Separate current/former members with calculable period dates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Add MemberGroup/BandGroup types and groupBandMembers/groupArtistMembers helpers
- Calculate membership duration in months from YYYY-MM since/until values
- Band view splits members into 在籍中 / 元メンバー sections with duration label
- Artist view splits bands into 在籍中 / 元在籍 sections with duration label
- Change since/until inputs to type="month" for structured data entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 app/routes/band-by-uuid.tsx | 97 ++++++++++++++++++++++++---------------------
 1 file changed, 52 insertions(+), 45 deletions(-)

(limited to 'app/routes/band-by-uuid.tsx')

diff --git a/app/routes/band-by-uuid.tsx b/app/routes/band-by-uuid.tsx
index 2335e14..603629e 100644
--- a/app/routes/band-by-uuid.tsx
+++ b/app/routes/band-by-uuid.tsx
@@ -5,9 +5,11 @@ import {
   getBandLinks,
   getBandMembers,
   getBandRevisions,
-  type BandMemberRow,
+  groupBandMembers,
+  type MemberGroup,
 } from "~/lib/db.server";
 import { LINK_TYPE_LABEL } from "~/lib/constants";
+import { formatDuration } from "~/lib/utils";
 
 export async function loader({ params }: LoaderFunctionArgs) {
   const band = getBandById(params.uuid!);
@@ -15,7 +17,8 @@ export async function loader({ params }: LoaderFunctionArgs) {
   const links = getBandLinks(band.id);
   const members = getBandMembers(band.id);
   const revisions = getBandRevisions(band.id);
-  return { band, links, members, latest: revisions[0] ?? null };
+  const grouped = groupBandMembers(members);
+  return { band, links, grouped, latest: revisions[0] ?? null };
 }
 
 const STATUS_LABEL: Record<string, string> = {
@@ -24,18 +27,45 @@ const STATUS_LABEL: Record<string, string> = {
   disbanded: "解散",
 };
 
-function periodLabel(m: BandMemberRow): string | null {
-  if (!m.since && !m.until) return null;
-  const from = m.since || "?";
-  const to = m.until || "現在";
-  return `${from} 〜 ${to}`;
+function periodRange(since: string, until: string): string | null {
+  if (!since && !until) return null;
+  return `${since || "?"} 〜 ${until || "現在"}`;
 }
 
-export default function BandDetail() {
-  const { band, links, members, latest } = useLoaderData<typeof loader>();
+function MemberItem({ group }: { group: MemberGroup }) {
+  const roles = [...new Set(group.periods.flatMap((p) =>
+    p.role ? p.role.split(", ").filter(Boolean) : []
+  ))];
+  const hasPeriodInfo = group.periods.some((p) => p.since || p.until || p.note);
+  return (
+    <li>
+      <div className="member-main">
+        <Link to={`/artists/of/${group.artist_id}`}>{group.artist_name}</Link>
+        {roles.map((r, i) => <span key={i} className="badge">{r}</span>)}
+        {group.duration_months !== null && (
+          <span className="muted">{formatDuration(group.duration_months)}</span>
+        )}
+      </div>
+      {hasPeriodInfo && (
+        <ul className="period-list">
+          {group.periods.map((p) => {
+            const range = periodRange(p.since, p.until);
+            if (!range && !p.note) return null;
+            return (
+              <li key={p.id} className="period-item">
+                {range && <span className="period-range">{range}</span>}
+                {p.note && <span className="period-note">{p.note}</span>}
+              </li>
+            );
+          })}
+        </ul>
+      )}
+    </li>
+  );
+}
 
-  // group members by artist_id for display
-  const artistIds = [...new Set(members.map((m) => m.artist_id))];
+export default function BandDetail() {
+  const { band, links, grouped, latest } = useLoaderData<typeof loader>();
 
   return (
     <main>
@@ -56,43 +86,20 @@ export default function BandDetail() {
         </div>
       </div>
 
-      {artistIds.length > 0 && (
+      {grouped.current.length > 0 && (
         <section>
           <h2>メンバー</h2>
           <ul className="member-list">
-            {artistIds.map((artistId) => {
-              const group = members.filter((m) => m.artist_id === artistId);
-              const first = group[0];
-              return (
-                <li key={artistId}>
-                  <div className="member-main">
-                    <Link to={`/artists/of/${artistId}`}>{first.artist_name}</Link>
-                    {group.flatMap((m) =>
-                      m.role ? m.role.split(", ").filter(Boolean).map((r, i) => (
-                        <span key={`${m.id}-${i}`} className="badge">{r}</span>
-                      )) : []
-                    ).filter((_, i, arr) => {
-                      // deduplicate role badges across periods (keep unique labels)
-                      return true;
-                    })}
-                  </div>
-                  {group.some((m) => m.since || m.until || m.note) && (
-                    <ul className="period-list">
-                      {group.map((m) => {
-                        const label = periodLabel(m);
-                        if (!label && !m.note) return null;
-                        return (
-                          <li key={m.id} className="period-item">
-                            {label && <span className="period-range">{label}</span>}
-                            {m.note && <span className="period-note">{m.note}</span>}
-                          </li>
-                        );
-                      })}
-                    </ul>
-                  )}
-                </li>
-              );
-            })}
+            {grouped.current.map((g) => <MemberItem key={g.artist_id} group={g} />)}
+          </ul>
+        </section>
+      )}
+
+      {grouped.former.length > 0 && (
+        <section>
+          <h2>元メンバー</h2>
+          <ul className="member-list former">
+            {grouped.former.map((g) => <MemberItem key={g.artist_id} group={g} />)}
           </ul>
         </section>
       )}
-- 
cgit v1.2.3

