diff options
| -rw-r--r-- | app/lib/db.server.ts | 80 | ||||
| -rw-r--r-- | app/lib/utils.ts | 7 | ||||
| -rw-r--r-- | app/routes/artist-by-uuid.tsx | 98 | ||||
| -rw-r--r-- | app/routes/band-by-uuid.tsx | 97 | ||||
| -rw-r--r-- | app/routes/band-edit.tsx | 4 | ||||
| -rw-r--r-- | app/routes/band-new.tsx | 4 |
6 files changed, 202 insertions, 88 deletions
diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts index cb51028..d3e5469 100644 --- a/app/lib/db.server.ts +++ b/app/lib/db.server.ts @@ -200,6 +200,86 @@ export interface ArtistRevision { created_at: string; } +export interface MemberGroup { + artist_id: string; + artist_name: string; + artist_slug: string; + periods: BandMemberRow[]; + is_current: boolean; + duration_months: number | null; +} + +export interface BandGroup { + band_id: string; + band_name: string; + band_slug: string; + periods: ArtistMemberRow[]; + is_current: boolean; + duration_months: number | null; +} + +function parseYearMonth(s: string): { year: number; month: number } | null { + const m = s.match(/^(\d{4})-(\d{2})$/); + if (!m) return null; + return { year: parseInt(m[1]), month: parseInt(m[2]) }; +} + +function calcDurationMonths(since: string, until: string): number | null { + const from = parseYearMonth(since); + if (!from) return null; + const today = new Date(); + const to = parseYearMonth(until) ?? { year: today.getFullYear(), month: today.getMonth() + 1 }; + return Math.max(0, (to.year * 12 + to.month) - (from.year * 12 + from.month)); +} + +export function groupBandMembers(members: BandMemberRow[]): { + current: MemberGroup[]; + former: MemberGroup[]; + all: MemberGroup[]; +} { + const byArtist = new Map<string, BandMemberRow[]>(); + for (const m of members) { + const list = byArtist.get(m.artist_id) ?? []; + list.push(m); + byArtist.set(m.artist_id, list); + } + const all: MemberGroup[] = []; + for (const [artistId, periods] of byArtist) { + const first = periods[0]; + const is_current = periods.some((p) => !p.until); + const duration_months = periods.reduce<number | null>((acc, p) => { + const d = calcDurationMonths(p.since, p.until); + return d === null ? acc : (acc ?? 0) + d; + }, null); + all.push({ artist_id: artistId, artist_name: first.artist_name, artist_slug: first.artist_slug, periods, is_current, duration_months }); + } + return { current: all.filter((g) => g.is_current), former: all.filter((g) => !g.is_current), all }; +} + +export function groupArtistMembers(members: ArtistMemberRow[]): { + current: BandGroup[]; + former: BandGroup[]; + all: BandGroup[]; +} { + const byBand = new Map<string, ArtistMemberRow[]>(); + for (const m of members) { + const list = byBand.get(m.band_id) ?? []; + list.push(m); + byBand.set(m.band_id, list); + } + const all: BandGroup[] = []; + for (const [bandId, periods] of byBand) { + const first = periods[0]; + const is_current = periods.some((p) => !p.until); + const duration_months = periods.reduce<number | null>((acc, p) => { + const d = calcDurationMonths(p.since, p.until); + return d === null ? acc : (acc ?? 0) + d; + }, null); + all.push({ band_id: bandId, band_name: first.band_name, band_slug: first.band_slug, periods, is_current, duration_months }); + } + return { current: all.filter((g) => g.is_current), former: all.filter((g) => !g.is_current), all }; +} + export function getIpAddress(request: Request): string { return ( request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? diff --git a/app/lib/utils.ts b/app/lib/utils.ts new file mode 100644 index 0000000..961aa31 --- /dev/null +++ b/app/lib/utils.ts @@ -0,0 +1,7 @@ +export function formatDuration(months: number): string { + const years = Math.floor(months / 12); + const m = months % 12; + if (years === 0) return `${m}ヶ月`; + if (m === 0) return `${years}年`; + return `${years}年${m}ヶ月`; +} 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<typeof loader>(); +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 ( + <li> + <div className="member-main"> + <Link to={`/bands/of/${group.band_id}`}>{group.band_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 by band_id - const bandIds = [...new Set(memberships.map((m) => m.band_id))]; +export default function ArtistDetail() { + const { artist, links, grouped, latest } = useLoaderData<typeof loader>(); return ( <main> @@ -34,36 +70,20 @@ export default function ArtistDetail() { </div> </div> - {bandIds.length > 0 && ( + {grouped.current.length > 0 && ( <section> - <h2>バンド</h2> + <h2>在籍中のバンド</h2> <ul className="member-list"> - {bandIds.map((bandId) => { - const group = memberships.filter((m) => m.band_id === bandId); - const first = group[0]; - return ( - <li key={bandId}> - <div className="member-main"> - <Link to={`/bands/of/${bandId}`}>{first.band_name}</Link> - {first.role && <span className="muted">{first.role}</span>} - </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) => <BandItem key={g.band_id} group={g} />)} + </ul> + </section> + )} + + {grouped.former.length > 0 && ( + <section> + <h2>元在籍バンド</h2> + <ul className="member-list former"> + {grouped.former.map((g) => <BandItem key={g.band_id} group={g} />)} </ul> </section> )} 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> )} 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() { </div> <div className="period-row"> <input + type="month" value={entry.since} onChange={(e) => updateEntry(entry.key, "since", e.target.value)} - placeholder="加入 (例: 2020-04)" /> <span className="period-sep">〜</span> <input + type="month" value={entry.until} onChange={(e) => updateEntry(entry.key, "until", e.target.value)} - placeholder="脱退 (空欄=在籍中)" /> <input className="period-note" diff --git a/app/routes/band-new.tsx b/app/routes/band-new.tsx index 0b7e17f..15e072f 100644 --- a/app/routes/band-new.tsx +++ b/app/routes/band-new.tsx @@ -248,15 +248,15 @@ export default function BandNew() { </div> <div className="period-row"> <input + type="month" value={entry.since} onChange={(e) => updateEntry(entry.key, "since", e.target.value)} - placeholder="加入 (例: 2020-04)" /> <span className="period-sep">〜</span> <input + type="month" value={entry.until} onChange={(e) => updateEntry(entry.key, "until", e.target.value)} - placeholder="脱退 (空欄=在籍中)" /> <input className="period-note" |
