From ec2417fc3e7029cb6fa84aa184daac2768ddad85 Mon Sep 17 00:00:00 2001 From: yyamashita 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 --- app/routes/artist-by-uuid.tsx | 98 ++++++++++++++++++++++++++----------------- app/routes/band-by-uuid.tsx | 97 ++++++++++++++++++++++-------------------- app/routes/band-edit.tsx | 4 +- app/routes/band-new.tsx | 4 +- 4 files changed, 115 insertions(+), 88 deletions(-) (limited to 'app/routes') diff --git a/app/routes/artist-by-uuid.tsx b/app/routes/artist-by-uuid.tsx index a65525b..7ddf318 100644 --- a/app/routes/artist-by-uuid.tsx +++ b/app/routes/artist-by-uuid.tsx @@ -1,6 +1,14 @@ import { data, Link, useLoaderData } from "react-router"; import type { LoaderFunctionArgs } from "react-router"; -import { getArtistById, getArtistLinks, getArtistMembers, getArtistRevisions, type ArtistMemberRow } from "~/lib/db.server"; +import { + getArtistById, + getArtistLinks, + getArtistMembers, + getArtistRevisions, + groupArtistMembers, + type BandGroup, +} from "~/lib/db.server"; +import { formatDuration } from "~/lib/utils"; export async function loader({ params }: LoaderFunctionArgs) { const artist = getArtistById(params.uuid!); @@ -8,21 +16,49 @@ export async function loader({ params }: LoaderFunctionArgs) { const links = getArtistLinks(artist.id); const memberships = getArtistMembers(artist.id); const revisions = getArtistRevisions(artist.id); - return { artist, links, memberships, latest: revisions[0] ?? null }; + const grouped = groupArtistMembers(memberships); + return { artist, links, grouped, latest: revisions[0] ?? null }; } -function periodLabel(m: ArtistMemberRow): 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 ArtistDetail() { - const { artist, links, memberships, latest } = useLoaderData(); +function BandItem({ group }: { group: BandGroup }) { + 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 ( +
  • +
    + {group.band_name} + {roles.map((r, i) => {r})} + {group.duration_months !== null && ( + {formatDuration(group.duration_months)} + )} +
    + {hasPeriodInfo && ( +
      + {group.periods.map((p) => { + const range = periodRange(p.since, p.until); + if (!range && !p.note) return null; + return ( +
    • + {range && {range}} + {p.note && {p.note}} +
    • + ); + })} +
    + )} +
  • + ); +} - // group by band_id - const bandIds = [...new Set(memberships.map((m) => m.band_id))]; +export default function ArtistDetail() { + const { artist, links, grouped, latest } = useLoaderData(); return (
    @@ -34,36 +70,20 @@ export default function ArtistDetail() { - {bandIds.length > 0 && ( + {grouped.current.length > 0 && (
    -

    バンド

    +

    在籍中のバンド

      - {bandIds.map((bandId) => { - const group = memberships.filter((m) => m.band_id === bandId); - const first = group[0]; - return ( -
    • -
      - {first.band_name} - {first.role && {first.role}} -
      - {group.some((m) => m.since || m.until || m.note) && ( -
        - {group.map((m) => { - const label = periodLabel(m); - if (!label && !m.note) return null; - return ( -
      • - {label && {label}} - {m.note && {m.note}} -
      • - ); - })} -
      - )} -
    • - ); - })} + {grouped.current.map((g) => )} +
    +
    + )} + + {grouped.former.length > 0 && ( +
    +

    元在籍バンド

    +
      + {grouped.former.map((g) => )}
    )} 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 = { @@ -24,18 +27,45 @@ const STATUS_LABEL: Record = { 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(); +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 ( +
  • +
    + {group.artist_name} + {roles.map((r, i) => {r})} + {group.duration_months !== null && ( + {formatDuration(group.duration_months)} + )} +
    + {hasPeriodInfo && ( +
      + {group.periods.map((p) => { + const range = periodRange(p.since, p.until); + if (!range && !p.note) return null; + return ( +
    • + {range && {range}} + {p.note && {p.note}} +
    • + ); + })} +
    + )} +
  • + ); +} - // 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(); return (
    @@ -56,43 +86,20 @@ export default function BandDetail() { - {artistIds.length > 0 && ( + {grouped.current.length > 0 && (

    メンバー

      - {artistIds.map((artistId) => { - const group = members.filter((m) => m.artist_id === artistId); - const first = group[0]; - return ( -
    • -
      - {first.artist_name} - {group.flatMap((m) => - m.role ? m.role.split(", ").filter(Boolean).map((r, i) => ( - {r} - )) : [] - ).filter((_, i, arr) => { - // deduplicate role badges across periods (keep unique labels) - return true; - })} -
      - {group.some((m) => m.since || m.until || m.note) && ( -
        - {group.map((m) => { - const label = periodLabel(m); - if (!label && !m.note) return null; - return ( -
      • - {label && {label}} - {m.note && {m.note}} -
      • - ); - })} -
      - )} -
    • - ); - })} + {grouped.current.map((g) => )} +
    +
    + )} + + {grouped.former.length > 0 && ( +
    +

    元メンバー

    +
      + {grouped.former.map((g) => )}
    )} diff --git a/app/routes/band-edit.tsx b/app/routes/band-edit.tsx index ffba70f..b64b707 100644 --- a/app/routes/band-edit.tsx +++ b/app/routes/band-edit.tsx @@ -271,15 +271,15 @@ export default function BandEdit() {
    updateEntry(entry.key, "since", e.target.value)} - placeholder="加入 (例: 2020-04)" /> updateEntry(entry.key, "until", e.target.value)} - placeholder="脱退 (空欄=在籍中)" />
    updateEntry(entry.key, "since", e.target.value)} - placeholder="加入 (例: 2020-04)" /> updateEntry(entry.key, "until", e.target.value)} - placeholder="脱退 (空欄=在籍中)" />