summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authoryyamashita <yyamashita@mosquit.one>2026-05-10 22:46:59 +0900
committeryyamashita <yyamashita@mosquit.one>2026-05-10 22:46:59 +0900
commitec2417fc3e7029cb6fa84aa184daac2768ddad85 (patch)
tree3d4b0d8ad3b90d3bb99f9c2932637f756090b57a /app
parent184e6947707ecdf07dfa3a5cbc6e51cf9440e93a (diff)
Separate current/former members with calculable period dates
- 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>
Diffstat (limited to 'app')
-rw-r--r--app/lib/db.server.ts80
-rw-r--r--app/lib/utils.ts7
-rw-r--r--app/routes/artist-by-uuid.tsx98
-rw-r--r--app/routes/band-by-uuid.tsx97
-rw-r--r--app/routes/band-edit.tsx4
-rw-r--r--app/routes/band-new.tsx4
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"