merge(feat/person-birth-death-years): resolve conflicts with main, bump migration to V6
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 1m45s
CI / Backend Unit Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Failing after 18m40s
CI / Unit & Component Tests (push) Successful in 1m57s
CI / Backend Unit Tests (push) Successful in 2m13s
CI / E2E Tests (push) Failing after 18m39s
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 1m45s
CI / Backend Unit Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Failing after 18m40s
CI / Unit & Component Tests (push) Successful in 1m57s
CI / Backend Unit Tests (push) Successful in 2m13s
CI / E2E Tests (push) Failing after 18m39s
Resolves merge conflicts with main (feat/person-notes merged first). Combines both features: birth/death years and notes field on person detail. Renames migration V5__add_birth_death_years to V6 to avoid Flyway conflict. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #28.
This commit is contained in:
@@ -307,6 +307,7 @@ export interface components {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
birthYear?: number;
|
||||
deathYear?: number;
|
||||
};
|
||||
|
||||
44
frontend/src/lib/utils/sort.spec.ts
Normal file
44
frontend/src/lib/utils/sort.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sortDocumentsByDate } from './sort';
|
||||
|
||||
const doc = (id: string, documentDate: string | null) =>
|
||||
({ id, documentDate } as { id: string; documentDate: string | null });
|
||||
|
||||
describe('sortDocumentsByDate', () => {
|
||||
it('sorts DESC by default — newest first', () => {
|
||||
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
|
||||
const result = sortDocumentsByDate(docs, 'DESC');
|
||||
expect(result.map((d) => d.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('sorts ASC — oldest first', () => {
|
||||
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
|
||||
const result = sortDocumentsByDate(docs, 'ASC');
|
||||
expect(result.map((d) => d.id)).toEqual(['a', 'c', 'b']);
|
||||
});
|
||||
|
||||
it('places documents without a date last in DESC', () => {
|
||||
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
|
||||
const result = sortDocumentsByDate(docs, 'DESC');
|
||||
expect(result[0].id).toBe('b');
|
||||
expect(result.slice(1).map((d) => d.id)).toContain('a');
|
||||
expect(result.slice(1).map((d) => d.id)).toContain('c');
|
||||
});
|
||||
|
||||
it('places documents without a date last in ASC', () => {
|
||||
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
|
||||
const result = sortDocumentsByDate(docs, 'ASC');
|
||||
expect(result[0].id).toBe('b');
|
||||
});
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const docs = [doc('a', '1950-01-01'), doc('b', '1920-01-01')];
|
||||
const original = [...docs];
|
||||
sortDocumentsByDate(docs, 'ASC');
|
||||
expect(docs).toEqual(original);
|
||||
});
|
||||
|
||||
it('returns an empty array unchanged', () => {
|
||||
expect(sortDocumentsByDate([], 'DESC')).toEqual([]);
|
||||
});
|
||||
});
|
||||
19
frontend/src/lib/utils/sort.ts
Normal file
19
frontend/src/lib/utils/sort.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type SortDir = 'ASC' | 'DESC';
|
||||
|
||||
/**
|
||||
* Returns a new array of documents sorted by documentDate.
|
||||
* Documents without a date are always placed last, regardless of direction.
|
||||
*/
|
||||
export function sortDocumentsByDate<T extends { documentDate?: string | null }>(
|
||||
docs: T[],
|
||||
dir: SortDir
|
||||
): T[] {
|
||||
return [...docs].sort((a, b) => {
|
||||
const da = a.documentDate ?? '';
|
||||
const db = b.documentDate ?? '';
|
||||
if (!da && !db) return 0;
|
||||
if (!da) return 1;
|
||||
if (!db) return -1;
|
||||
return dir === 'DESC' ? db.localeCompare(da) : da.localeCompare(db);
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export const actions = {
|
||||
const firstName = formData.get('firstName')?.toString().trim();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||
const birthYear = formData.get('birthYear')?.toString().trim() || undefined;
|
||||
const deathYear = formData.get('deathYear')?.toString().trim() || undefined;
|
||||
|
||||
@@ -41,6 +42,7 @@ export const actions = {
|
||||
body: {
|
||||
firstName, lastName,
|
||||
...(alias ? { alias } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
...(birthYear ? { birthYear } : {}),
|
||||
...(deathYear ? { deathYear } : {})
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
import { enhance } from '$app/forms';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const person = $derived(data.person);
|
||||
const documents = $derived(data.documents);
|
||||
|
||||
let sortDir = $state<SortDir>('DESC');
|
||||
const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir));
|
||||
|
||||
let editMode = $state(false);
|
||||
let mergeTargetId = $state('');
|
||||
let showMergeConfirm = $state(false);
|
||||
@@ -108,6 +112,16 @@
|
||||
class="block w-full border border-gray-300 rounded px-3 py-2 font-serif text-brand-navy focus:outline-none focus:border-brand-navy"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class="block text-xs font-bold uppercase tracking-widest text-gray-400 mb-1">{m.person_label_notes()}</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows="4"
|
||||
placeholder={m.person_placeholder_notes()}
|
||||
class="block w-full border border-gray-300 rounded px-3 py-2 font-serif text-brand-navy focus:outline-none focus:border-brand-navy resize-y"
|
||||
>{person.notes ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
@@ -134,10 +148,16 @@
|
||||
<h1 class="text-4xl font-serif text-brand-navy">
|
||||
{person.firstName} {person.lastName}
|
||||
</h1>
|
||||
<button onclick={() => (editMode = true)} class="ml-4 flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
|
||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg" alt="" aria-hidden="true" class="w-3.5 h-3.5" />
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
<div class="ml-4 flex-shrink-0 flex items-center gap-2">
|
||||
<a href="/conversations?senderId={person.id}" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
||||
{m.person_btn_conversations()}
|
||||
</a>
|
||||
<button onclick={() => (editMode = true)} class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
|
||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg" alt="" aria-hidden="true" class="w-3.5 h-3.5" />
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
@@ -163,6 +183,13 @@
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if person.notes}
|
||||
<div class="md:col-span-2">
|
||||
<span class="block text-xs font-bold font-sans text-gray-400 uppercase tracking-widest mb-1">{m.person_label_notes()}</span>
|
||||
<p class="text-base font-serif text-brand-navy whitespace-pre-wrap">{person.notes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,10 +264,21 @@
|
||||
<!-- Linked Documents Section -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6 border-b border-brand-navy/10 pb-2">
|
||||
<h2 class="text-xl font-serif text-brand-navy">{m.person_docs_heading()}</h2>
|
||||
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
|
||||
{documents.length}
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-serif text-brand-navy">{m.person_docs_heading()}</h2>
|
||||
<span class="bg-brand-navy text-white text-xs font-bold px-2 py-1 rounded-full">
|
||||
{documents.length}
|
||||
</span>
|
||||
</div>
|
||||
{#if documents.length > 0}
|
||||
<button
|
||||
onclick={() => (sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC')}
|
||||
class="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-brand-navy transition-colors"
|
||||
aria-label={m.conv_sort_label()}
|
||||
>
|
||||
{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if documents.length === 0}
|
||||
@@ -249,7 +287,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-3">
|
||||
{#each documents as doc}
|
||||
{#each sortedDocuments as doc}
|
||||
<li class="group">
|
||||
<a href="/documents/{doc.id}" class="block bg-white border border-brand-sand p-4 hover:border-brand-navy hover:shadow-md transition-all duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
Reference in New Issue
Block a user