feat(family): enhance family view and character pregnancy handling
All checks were successful
Deploy to production / deploy (push) Successful in 2m48s

- Updated the FalukantCharacter model to include a default scope that excludes pregnancy-related fields for compatibility with older databases.
- Implemented a new method in FalukantService to conditionally retrieve pregnancy information based on database schema.
- Enhanced the FamilyView component to display a summary navigation for family relationships, including partners, children, and lovers.
- Updated internationalization files to include new translations for family-related terms and summaries.
This commit is contained in:
Torsten Schulz (local)
2026-03-30 14:36:02 +02:00
parent f35db4b1a1
commit 2b83c45e97
6 changed files with 307 additions and 79 deletions

View File

@@ -61,7 +61,12 @@ FalukantCharacter.init(
tableName: 'character',
schema: 'falukant_data',
timestamps: true,
underscored: true}
underscored: true,
// Spalten erst nach Migration 20260330000000; ohne Exclude würde SELECT/INSERT auf alten DBs fehlschlagen
defaultScope: {
attributes: { exclude: ['pregnancyDueAt', 'pregnancyFatherCharacterId'] },
},
}
);
export default FalukantCharacter;

View File

@@ -3320,11 +3320,31 @@ class FalukantService extends BaseService {
return { result: 'ok' };
}
/** Liest Schwangerschaft nur wenn DB-Spalten existieren (nach Migration). */
async _getCharacterPregnancyOptional(characterId) {
try {
const rows = await sequelize.query(
`SELECT pregnancy_due_at, pregnancy_father_character_id
FROM falukant_data."character" WHERE id = :id`,
{ replacements: { id: characterId }, type: Sequelize.QueryTypes.SELECT }
);
const row = rows[0];
if (!row?.pregnancy_due_at) return null;
return {
dueAt: row.pregnancy_due_at,
fatherCharacterId: row.pregnancy_father_character_id,
};
} catch {
return null;
}
}
async getFamily(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user) throw new Error('User not found');
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
if (!character) throw new Error('Character not found for this user');
const pregnancy = await this._getCharacterPregnancyOptional(character.id);
// Load relationships without includes to avoid EagerLoadingError
const relRows = await Relationship.findAll({
where: { character1Id: character.id },
@@ -3519,12 +3539,7 @@ class FalukantService extends BaseService {
children: children.map(({ _createdAt, ...rest }) => rest),
possiblePartners: [],
possibleLovers: [],
pregnancy: character.pregnancyDueAt
? {
dueAt: character.pregnancyDueAt,
fatherCharacterId: character.pregnancyFatherCharacterId,
}
: null,
pregnancy,
};
const ownAge = calcAge(character.birthdate);
if (ownAge >= 12) {

View File

@@ -536,6 +536,20 @@
},
"family": {
"title": "Familie",
"heroIntro": "Beziehungen, Kinder und Entwicklung — unten nach Bereichen geordnet.",
"summary": {
"partnerChip": "Partner",
"childrenChip": "Kinder",
"loversChip": "Liebschaften",
"proposalsAvailable": "Verlobung möglich",
"noPartner": "Kein Partner"
},
"tabs": {
"partner": "Partner & Ehe",
"children": "Kinder",
"lovers": "Liebschaften"
},
"tabsAria": "Familienbereiche",
"debtorsPrison": {
"familyWarning": "Anhaltender Kreditverzug belastet Ehe, Haushalt und Liebschaften.",
"familyImpact": "Der Schuldturm schadet Ehe, Hausfrieden und der Stabilität von Liebschaften."
@@ -546,6 +560,7 @@
},
"spouse": {
"title": "Beziehung",
"traitsToggle": "Charaktereigenschaften",
"name": "Name",
"age": "Alter",
"status": "Status",

View File

@@ -607,6 +607,21 @@
}
},
"family": {
"title": "Family",
"heroIntro": "Relationships, children and development — organized by section below.",
"summary": {
"partnerChip": "Partner",
"childrenChip": "Children",
"loversChip": "Affairs",
"proposalsAvailable": "Betrothal available",
"noPartner": "No partner"
},
"tabs": {
"partner": "Partner & marriage",
"children": "Children",
"lovers": "Affairs"
},
"tabsAria": "Family sections",
"debtorsPrison": {
"familyWarning": "Ongoing debt delinquency puts strain on marriage, household and affairs.",
"familyImpact": "Debtors' prison damages marriage, household peace and the stability of affairs."
@@ -653,6 +668,7 @@
}
},
"spouse": {
"traitsToggle": "Character traits",
"marriageSatisfaction": "Marriage Satisfaction",
"marriageState": "Marriage State",
"wooing": {

View File

@@ -517,6 +517,20 @@
},
"family": {
"title": "Familia",
"heroIntro": "Relaciones, hijos y desarrollo — ordenado por secciones abajo.",
"summary": {
"partnerChip": "Pareja",
"childrenChip": "Hijos",
"loversChip": "Relaciones",
"proposalsAvailable": "Compromiso posible",
"noPartner": "Sin pareja"
},
"tabs": {
"partner": "Pareja y matrimonio",
"children": "Hijos",
"lovers": "Relaciones"
},
"tabsAria": "Secciones de familia",
"debtorsPrison": {
"familyWarning": "La mora continuada perjudica el matrimonio, el hogar y las relaciones.",
"familyImpact": "La prisión por deudas daña el matrimonio, la paz del hogar y la estabilidad de las relaciones."
@@ -527,6 +541,7 @@
},
"spouse": {
"title": "Relación",
"traitsToggle": "Rasgos de carácter",
"name": "Nombre",
"age": "Edad",
"status": "Estado",

View File

@@ -7,7 +7,7 @@
<div>
<span class="family-kicker">Familie</span>
<h2>{{ $t('falukant.family.title') }}</h2>
<p>Beziehungen, Kinder und familiäre Entwicklung in einer eigenen Spielweltansicht.</p>
<p>{{ $t('falukant.family.heroIntro') }}</p>
</div>
</section>
@@ -36,62 +36,116 @@
</p>
</section>
<nav
class="family-summary-strip surface-card"
role="tablist"
:aria-label="$t('falukant.family.tabsAria')"
>
<button
type="button"
role="tab"
:aria-selected="familyTab === 'partner'"
class="family-summary-chip"
:class="{ 'is-active': familyTab === 'partner' }"
:title="$t('falukant.family.tabs.partner')"
@click="familyTab = 'partner'"
>
<span class="family-summary-chip__label">{{ $t('falukant.family.summary.partnerChip') }}</span>
<span class="family-summary-chip__value">{{ partnerSummaryLine }}</span>
</button>
<button
type="button"
role="tab"
:aria-selected="familyTab === 'children'"
class="family-summary-chip"
:class="{ 'is-active': familyTab === 'children' }"
:title="$t('falukant.family.tabs.children')"
@click="familyTab = 'children'"
>
<span class="family-summary-chip__label">{{ $t('falukant.family.summary.childrenChip') }}</span>
<span class="family-summary-chip__value">{{ children.length }}</span>
</button>
<button
type="button"
role="tab"
:aria-selected="familyTab === 'lovers'"
class="family-summary-chip"
:class="{ 'is-active': familyTab === 'lovers' }"
:title="$t('falukant.family.tabs.lovers')"
@click="familyTab = 'lovers'"
>
<span class="family-summary-chip__label">{{ $t('falukant.family.summary.loversChip') }}</span>
<span class="family-summary-chip__value">{{ lovers.length }}</span>
</button>
</nav>
<div
v-show="familyTab === 'partner'"
class="family-tab-panel"
role="tabpanel"
>
<div class="spouse-section">
<h3>{{ $t('falukant.family.spouse.title') }}</h3>
<div v-if="relationships.length > 0" class="relationship-container">
<div class="relationship-row">
<div class="relationship">
<table>
<tr>
<td>{{ $t('falukant.family.relationships.name') }}</td>
<td>
<dl class="spouse-card__dl">
<div>
<dt>{{ $t('falukant.family.relationships.name') }}</dt>
<dd>
{{ $t('falukant.titles.' + relationships[0].character2.gender + '.' +
relationships[0].character2.nobleTitle) }}
{{ relationships[0].character2.firstName }}
</td>
</tr>
<tr>
<td>{{ $t('falukant.family.spouse.age') }}</td>
<td>{{ relationships[0].character2.age }}</td>
</tr>
<tr>
<td>{{ $t('falukant.family.spouse.mood') }}</td>
<td>{{ relationships[0].character2.mood?.tr ? $t(`falukant.mood.${relationships[0].character2.mood.tr}`) : '—' }}</td>
</tr>
<tr>
<td>{{ $t('falukant.family.spouse.status') }}</td>
<td>{{ $t('falukant.family.statuses.' + relationships[0].relationshipType) }}</td>
</tr>
<tr v-if="relationships[0].marriageSatisfaction != null">
<td>{{ $t('falukant.family.spouse.marriageSatisfaction') }}</td>
<td>
</dd>
</div>
<div>
<dt>{{ $t('falukant.family.spouse.age') }}</dt>
<dd>{{ relationships[0].character2.age }}</dd>
</div>
<div>
<dt>{{ $t('falukant.family.spouse.mood') }}</dt>
<dd>{{ relationships[0].character2.mood?.tr ? $t(`falukant.mood.${relationships[0].character2.mood.tr}`) : '—' }}</dd>
</div>
<div>
<dt>{{ $t('falukant.family.spouse.status') }}</dt>
<dd>{{ $t('falukant.family.statuses.' + relationships[0].relationshipType) }}</dd>
</div>
<div v-if="relationships[0].marriageSatisfaction != null">
<dt>{{ $t('falukant.family.spouse.marriageSatisfaction') }}</dt>
<dd>
{{ relationships[0].marriageSatisfaction }}
<span class="inline-status-pill" :class="`inline-status-pill--${relationships[0].marriageState || 'stable'}`">
{{ $t('falukant.family.marriageState.' + (relationships[0].marriageState || 'stable')) }}
</span>
</td>
</tr>
<tr v-if="relationships[0].relationshipType === 'wooing'">
<td>{{ $t('falukant.family.spouse.progress') }}</td>
<td>
</dd>
</div>
<div v-if="relationships[0].relationshipType === 'wooing'">
<dt>{{ $t('falukant.family.spouse.progress') }}</dt>
<dd>
<div class="progress">
<div class="progress-inner" :style="{
width: normalizeWooingProgress(relationships[0].progress) + '%',
backgroundColor: progressColor(relationships[0].progress)
}"></div>
</div>
</td>
</tr>
<tr v-if="relationships[0].relationshipType === 'engaged'" colspan="2">
<button @click="jumpToPartyForm">{{ $t('falukant.family.spouse.jumpToPartyForm')
}}</button>
</tr>
</table>
<ul>
<li v-for="trait in relationships[0].character2.traits" :key="trait.id">
{{ $t(`falukant.character.${trait.tr}`) }}
</li>
</ul>
</dd>
</div>
</dl>
<div v-if="relationships[0].relationshipType === 'engaged'" class="spouse-card__cta">
<button type="button" class="button" @click="jumpToPartyForm">{{ $t('falukant.family.spouse.jumpToPartyForm')
}}</button>
</div>
<details
v-if="relationships[0].character2.traits?.length"
class="spouse-traits"
>
<summary>{{ $t('falukant.family.spouse.traitsToggle') }}</summary>
<ul>
<li v-for="trait in relationships[0].character2.traits" :key="trait.id">
{{ $t(`falukant.character.${trait.tr}`) }}
</li>
</ul>
</details>
</div>
<div class="partner-character-3d">
<Character3D
@@ -208,7 +262,13 @@
</div>
</div>
</section>
</div>
<div
v-show="familyTab === 'children'"
class="family-tab-panel"
role="tabpanel"
>
<div class="children-section">
<h3>{{ $t('falukant.family.children.title') }}</h3>
<div v-if="children && children.length > 0" class="children-container">
@@ -262,7 +322,13 @@
<p>{{ $t('falukant.family.children.none') }}</p>
</div>
</div>
</div>
<div
v-show="familyTab === 'lovers'"
class="family-tab-panel"
role="tabpanel"
>
<!-- Liebhaber / Geliebte -->
<div class="lovers-section">
<h3>{{ $t('falukant.family.lovers.title') }}</h3>
@@ -367,6 +433,7 @@
<p v-else>{{ $t('falukant.family.lovers.candidates.none') }}</p>
</div>
</div>
</div>
</div>
</div>
<ChildDetailsDialog ref="childDetailsDialog" />
@@ -417,11 +484,27 @@ export default {
},
pregnancy: null,
selectedChild: null,
pendingFamilyRefresh: null
pendingFamilyRefresh: null,
familyTab: 'partner',
}
},
computed: {
...mapState(['socket', 'daemonSocket', 'user'])
...mapState(['socket', 'daemonSocket', 'user']),
partnerSummaryLine() {
if (this.relationships?.length > 0) {
const r = this.relationships[0];
const c2 = r.character2;
const name = c2
? `${this.$t(`falukant.titles.${c2.gender}.${c2.nobleTitle}`)} ${c2.firstName}`.trim()
: '';
const status = this.$t(`falukant.family.statuses.${r.relationshipType}`);
return name ? `${name} · ${status}` : status;
}
if (this.proposals?.length > 0) {
return this.$t('falukant.family.summary.proposalsAvailable');
}
return this.$t('falukant.family.summary.noPartner');
},
},
watch: {
socket(newVal, oldVal) {
@@ -725,26 +808,6 @@ export default {
return new Intl.NumberFormat(navigator.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
},
getEffect(gift) {
const relationship = this.relationships[0];
if (!relationship || !relationship.character2) {
return 0;
}
const partner = relationship.character2;
const currentMoodId = partner.moodId;
const moodEntry = gift.moodsAffects.find(ma => ma.mood_id === currentMoodId);
const moodValue = moodEntry ? moodEntry.suitability : 0;
let highestCharacterValue = 0;
// traits ist ein Array von Trait-Objekten mit id und tr
for (const trait of partner.traits || []) {
const charEntry = gift.charactersAffects.find(ca => ca.trait_id === trait.id);
if (charEntry && charEntry.suitability > highestCharacterValue) {
highestCharacterValue = charEntry.suitability;
}
}
return Math.round((moodValue + highestCharacterValue) / 2);
},
async acceptProposal() {
const response = await apiClient.post('/api/falukant/family/acceptmarriageproposal'
, { proposalId: this.selectedProposalId });
@@ -955,6 +1018,116 @@ export default {
color: var(--color-text-secondary);
}
.family-summary-strip {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
padding: 12px 14px;
}
.family-summary-chip {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: rgba(255, 252, 247, 0.95);
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
min-width: 0;
flex: 1 1 140px;
max-width: 100%;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.family-summary-chip:hover {
border-color: rgba(248, 162, 43, 0.45);
}
.family-summary-chip.is-active {
border-color: rgba(248, 162, 43, 0.65);
box-shadow: 0 0 0 1px rgba(248, 162, 43, 0.2);
}
.family-summary-chip__label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.family-summary-chip__value {
font-size: 0.92rem;
font-weight: 600;
line-height: 1.35;
overflow-wrap: anywhere;
}
.family-tab-panel {
margin-bottom: 8px;
}
.spouse-card__dl {
margin: 0;
display: grid;
gap: 10px 16px;
}
.spouse-card__dl > div {
display: grid;
grid-template-columns: minmax(7rem, 34%) 1fr;
gap: 8px 12px;
align-items: baseline;
}
.spouse-card__dl dt {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.82rem;
font-weight: 600;
}
.spouse-card__dl dd {
margin: 0;
font-weight: 600;
}
.spouse-card__cta {
margin-top: 12px;
}
.spouse-traits {
margin-top: 14px;
padding: 10px 12px;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
background: rgba(255, 250, 243, 0.6);
}
.spouse-traits summary {
cursor: pointer;
font-weight: 600;
font-size: 0.88rem;
color: var(--color-text-secondary);
}
.spouse-traits ul {
margin: 10px 0 0;
padding-left: 1.1rem;
}
@media (max-width: 480px) {
.spouse-card__dl > div {
grid-template-columns: 1fr;
}
}
.marriage-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -1335,17 +1508,6 @@ export default {
width: 50px;
}
.relationship>table,
.relationship>ul {
display: inline-block;
margin-right: 1em;
vertical-align: top;
}
.relationship>ul {
list-style: none;
}
.progress {
width: 100%;
background-color: #e5e7eb;