Extract shared utility functions to eliminate duplication #194
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Frontend complexity audit identified several utility functions duplicated across components. These are pure-logic extractions that don't change any component markup.
Scope
Each item is one atomic commit:
1. Deduplicate
timeAgo/relativeTimeCommentThread.sveltehas its owntimeAgo()(lines 70-79)$lib/utils/notifications.tsexportsrelativeTime()— same logic, different nametimeAgofrom CommentThread, importrelativeTime(or move it to a shared$lib/utils/time.tsif we want to decouple it from notifications)2. Consolidate dual
formatDate$lib/utils/date.tsexportsformatDate()with fixed long format$lib/utils/personFormat.tsexports its ownformatDate()with'short'|'long'optionsformatDate(iso, format)indate.ts. HavepersonFormat.tsre-export or call through.3. Move
getInitialstopersonFormat.tsCommentThread.sveltehasgetInitials()(lines 89-95)users/[id]/+page.sveltederives initials inlinePersonChip.sveltecomputes initials inlinegetInitials(name: string): stringto$lib/utils/personFormat.ts, import everywhere4. Extract
useUnsavedWarning.svelte.tshookadmin/groups/[id],admin/tags/[id],admin/users/[id]isDirty,showUnsavedWarning,discardTargetbeforeNavigateguard, form success reset$lib/hooks/useUnsavedWarning.svelte.tsreturning{ isDirty, showUnsavedWarning, discardTarget, markDirty, discard }UnsavedWarningBanner.sveltefor the shared amber alert markup5. Extract
useFileLoader.svelte.tshookdocuments/[id]/+page.svelte(lines 26-47) andenrich/[id]/+page.sveltehave identical blob-URL file loading$lib/hooks/useFileLoader.svelte.tsreturning{ fileUrl, isLoading, fileError, loadFile }6. Extract
useConversationFilters.svelte.tshookbriefwechsel/+page.sveltehas filter state +applyFilters()+toggleSort()+swapPersons()$lib/hooks/useConversationFilters.svelte.tsencapsulating filter state and URL syncAcceptance Criteria
npm run checkpasses👨💻 Felix Brandt -- Senior Fullstack Developer
Good refactoring scope. A few things I want to nail down before implementation:
Item 1 --
timeAgo/relativeTimetimeAgofromCommentThread.svelte, I'd want to diff the two implementations line by line. "Same logic" can hide edge cases (e.g., does one handlenulldates? Different rounding for "just now"?). Have we actually confirmed they produce identical output for all inputs?$lib/utils/time.tsis the right call --relativeTimehas nothing to do with notifications conceptually. The namerelativeTimeis better thantimeAgosince it describes the return value, not the direction.Item 2 -- Consolidate
formatDatepersonFormat.tsversion accepts'short' | 'long'-- thedate.tsversion is fixedlong. Consolidating means the new signature isformatDate(iso: string, format?: 'short' | 'long')with'long'as default. That preserves backward compatibility at every existing call site. Just want to confirm that's the plan.date.tsis the natural home.personFormat.tsshould import fromdate.ts, not re-export -- re-exports create import confusion when two paths resolve to the same function.Item 3 --
getInitialsItem 4 --
useUnsavedWarninghookbeforeNavigateguard interacts with SvelteKit's navigation lifecycle -- the hook needs to properly callnavigation.cancel()and clean up on component destroy.{ isDirty, showUnsavedWarning, discardTarget, markDirty, discard }mixes state and commands. That's fine for a hook, butisDirtyshould be a$derivedfrom form state comparison, not a manually toggled boolean. Is the current implementation doing manual tracking or actual dirty-checking?UnsavedWarningBanner.svelteextraction makes sense -- it's a distinct visual region with its own markup.Item 5 --
useFileLoaderhookURL.revokeObjectURL()on cleanup. Is the current code indocuments/[id]/+page.sveltedoing this correctly? If not, this extraction is also a bug fix -- and per project rules, the bug fix should be a separate commit with its own test.loadFileshould accept the document ID and fetch path, not hardcode the endpoint.Item 6 --
useConversationFiltershookbriefwechsel/+page.svelteis the only consumer, I'd defer this until a second consumer appears.gotowith search params) couples this hook to SvelteKit's router. That's acceptable but should be explicit in the hook's contract.General
$lib/hooks/directory doesn't exist yet. Establishing the convention with items 4 and 5 is fine, but let's keep the naming pattern consistent:use[Feature].svelte.tsfor hooks that use Svelte runes, plain.tsfor pure logic.$lib/utils/__tests__/. For items 4-5, tests go in$lib/hooks/__tests__/.🏗️ Markus Keller -- Senior Application Architect
This is a well-scoped refactoring issue. The module boundaries and file locations matter here -- a few observations:
Module structure decisions
$lib/utils/vs$lib/hooks/: The issue implicitly introduces a newhooks/directory. This is the right separation --utils/for pure functions (no framework dependency),hooks/for Svelte-rune-dependent state capsules. Worth documenting this convention once so it doesn't erode.personFormat.tsshould depend ondate.ts, never the reverse. Similarly, components import fromutils/andhooks/, never the other way. This is a one-way dependency graph:components -> hooks -> utils. Is this the intended layering?Item-by-item
Item 1 (timeAgo/relativeTime): Moving to
$lib/utils/time.tsis cleaner than keeping it innotifications.ts.notifications.tsis a domain module; relative time formatting is a general utility. Correct separation of concerns.Item 2 (formatDate): One
formatDateindate.tswith an options parameter is the right consolidation. But be careful about the Intl.DateTimeFormat instantiation -- ifformatDateis called in a tight loop (e.g., rendering 100 documents), creating a new formatter per call is wasteful. Consider caching the formatter instances per locale+format combination, or at minimum, note that this is a known optimization point.Item 3 (getInitials): Clean extraction.
personFormat.tsis the right home -- it already owns person display logic. No architectural concern here.Item 4 (useUnsavedWarning): This is the highest-value extraction. Three identical implementations in admin pages is exactly the kind of accidental complexity that grows quietly. Two questions:
beforeNavigateregistration and teardown, or does the consuming component still need to wire that up? The hook should own the full lifecycle.UnsavedWarningBanner.sveltecomponent -- does it receive state via props from the hook, or does it internally use the hook? Props from the parent is the correct pattern (data flows down).Item 5 (useFileLoader): The blob URL pattern appears in two places, and the lifecycle management (create/revoke) is easy to get wrong. Centralizing this is justified even at two occurrences because the failure mode (memory leak via unreleased blob URLs) is silent.
Item 6 (useConversationFilters): I share the concern about single-consumer extraction. The issue acknowledges this is "future-proofing" -- that's usually a signal to defer. The filter state + URL sync pattern in
briefwechsel/+page.svelteis page-specific today. I'd recommend: keep this as item 6, implement it last, and only if the first five items reveal that the pattern genuinely generalizes.Commit ordering
The issue says "each item is one atomic commit." Suggested dependency order:
This ordering minimizes merge conflicts and lets each commit build on the previous directory structure.
🧪 Sara Holt -- Senior QA Engineer
The acceptance criteria mention "new utility functions have unit tests" -- here's what I'd want to see for each item:
Test strategy per item
Item 1 --
relativeTime(moved totime.ts)notifications.tshas tests, we need to migrate those tests to match the new path. If it's untested today, this extraction is the right moment to add coverage.null/undefinedinput, future dates, dates exactly on boundary values (59 seconds vs 1 minute, 23 hours vs 1 day), very old dates.vi.useFakeTimers()withvi.setSystemTime()) -- nevernew Date()in assertions.Item 2 -- consolidated
formatDate'short'format,'long'format, default (no format arg), invalid ISO string, empty string,null.Intl.DateTimeFormat('de-DE'). Tests should verify German output format explicitly. If we ever add locale as a parameter, the test suite is already ready.T12:00:00suffix to prevent timezone off-by-one (per CLAUDE.md) -- there should be a test that catches the off-by-one if the suffix is removed. This is a regression guard.Item 3 --
getInitialsItem 4 --
useUnsavedWarningbeforeNavigate. Pure unit testing won't catch navigation lifecycle issues.beforeNavigatecallback receives a navigation object -- mock it carefully. Verifynavigation.cancel()is called when dirty, and not called when clean.Item 5 --
useFileLoaderisLoading = true, successful load setsfileUrlandisLoading = false, failed load setsfileError, cleanup callsURL.revokeObjectURL.fetchandURL.createObjectURL/URL.revokeObjectURL-- verify the revoke call happens on both cleanup and re-load (replacing a previous URL).Item 6 --
useConversationFiltersapplyFiltersupdates URL params,toggleSortflips direction,swapPersonsexchanges sender/receiver, filter reset clears all state.gotofrom$app/navigation.Regression risk
CI impact
🔒 Nora "NullX" Steiner -- Application Security Engineer
This is a pure frontend refactoring issue with no new endpoints or auth changes. Low security risk overall, but a few items warrant attention:
Item 5 --
useFileLoaderhook (primary concern)This is the only item that touches a security-relevant boundary -- it creates blob URLs from server-fetched file content.
connect-srcrestrictions in some browsers. The hook must callURL.revokeObjectURL()on component teardown and when replacing a previous URL. Verify the current implementations do this correctly -- if they don't, the extraction should fix it.URL.createObjectURL(blob), the blob's MIME type comes from the server'sContent-Typeheader. If the server ever returnstext/htmlfor an uploaded file, the blob URL becomes an XSS vector when rendered in an<iframe>or<object>. Confirm that the file preview component setssandboxon any iframe displaying blob URLs, or that the backend forces safe Content-Types (e.g.,application/pdf,image/*).fileErrorstate should not leak internal server details (stack traces, file paths). Ensure the error message is sanitized to a user-friendly string.Item 4 --
useUnsavedWarninghook (minor)discardTargetstores a URL that the user intended to navigate to. If this URL comes from user-controlled input (e.g., a query parameter), ensure it's validated before passing togoto(). Open redirect viadiscardTargetis low-risk in a SvelteKit app (sincegotois client-side), but worth a defensive check.Items 1-3 -- Pure utility functions (no concern)
relativeTime,formatDate, andgetInitialsare pure string transformations with no user input flowing to security-sensitive sinks. No issues.Item 6 --
useConversationFilters(minor)goto()-- ensure these are encoded properly. SvelteKit'sgotowithURLSearchParamshandles encoding automatically, but if anyone manually constructs the URL string, that's an injection point. Prefer theURLSearchParamsAPI.General
🎨 Leonie Voss -- UI/UX Design Lead
This issue is explicitly "pure-logic extractions that don't change any component markup" -- so no visual regressions expected. Still, a few items touch user-facing behavior worth watching:
Item 4 --
UnsavedWarningBanner.sveltebg-amber-50 border border-amber-200 rounded-smwith consistent padding. Confirm it meets the 4.5:1 contrast ratio for the warning text against the amber background.Item 1 --
relativeTimedisplayrelativeTimeoutput appears in the conversation thread UI. If the function signature or output format changes during consolidation (e.g., "vor 2 Stunden" vs "2h ago"), that's a visible UI change. Ensure the consolidated function produces the same German-language output the user currently sees.Item 2 --
formatDateconsolidation'short'vs'long'format distinction matters for the UI. Short format is typically used in dense lists (person detail, document cards), long format in detail views. After consolidation, verify that every call site still passes the correct format argument -- a missing argument defaulting to'long'in a place that currently shows'short'would be a visual regression.Item 3 --
getInitialsin avatarsgetInitialsoutput is displayed in avatar circles (CommentThread, PersonChip). The visual space is tight -- typically a 32-40px circle. If the extracted function returns 3 characters for hyphenated names (e.g., "AMR" for "Anna-Maria Raddatz"), the text may overflow the circle. Define maximum 2 characters as the contract, truncating to first and last initials.No visual spec needed
Since this doesn't introduce new UI, no spec file is required. But I'd recommend a quick visual check (manual or screenshot test) of the conversation thread and admin pages after implementation to confirm nothing shifted.
⚙️ Tobias Wendt -- DevOps & Platform Engineer
Pure frontend refactoring -- no infrastructure changes needed. A few CI and build considerations:
New directory
$lib/hooks/.svelte.tsfiles in$lib/hooks/automatically via the$libalias. No build config changes needed.$lib/hooks/__tests__/*.test.tswithout configuration changes, assuming the existingvitest.config.tsincludes$libin the test paths. Worth a quick check before starting.CI pipeline impact
npm run checkandnpm run teststeps in CI already cover this. No pipeline changes required.useUnsavedWarninglives) and the document detail page (whereuseFileLoaderlives).Tree-shaking / bundle size
$lib/imports should not increase bundle size -- Vite's tree-shaking handles this. But iftime.tsordate.tsimports grow (e.g., pulling in a date library), that could affect the client bundle.npm run buildand compare the output size. If any chunk grows by more than 1KB, investigate.Dependency changes
node_modulesor lockfile changes to worry about.Suggested validation before merge
If all four pass, the refactoring is safe from a build/CI perspective. The existing E2E suite provides the integration safety net.
Addressing Review Feedback
All concerns verified against source code. Responses organized by persona.
Re: Felix Brandt
timeAgovsrelativeTimeare NOT identical.timeAgo(CommentThread.svelte:70-79) uses Paraglide translation keys (m.comment_time_just_now(),m.comment_time_minutes(), etc.) and returns translated German strings.relativeTime(notifications.ts:15-24) usesIntl.RelativeTimeFormatwith the browser locale. Different output for the same input. The consolidation must use the Paraglide approach (CommentThread's logic) since that's i18n-correct. Moving to$lib/utils/time.tsand using Paraglide keys is the plan.formatDate(iso, format?: 'short' | 'long')with'long'as default indate.ts.personFormat.tswill import fromdate.ts, not re-export. Current consumers:date.tsformatDate(no format arg, always long) is used byConversationTimeline.svelte,DocumentList.svelte,PersonDocumentList.svelte,DocumentMetadataDrawer.svelte.personFormat.tsformatDate(requires format arg) is used byDocumentTopBar.svelteand internally byformatXsMeta. All call sites will work with the consolidated signature. Note:DashboardRecentDocuments.svelte:22has a third localformatDatethat handles full ISO datetimes (not date-only) withgetLocale()-- this one stays separate since it serves a different purpose (datetime vs date).firstName.charAt(0) + lastName.charAt(0)), users/[id]/+page.svelte:12 (handles first-only, last-only, both, returns null for neither), DocumentMetadataDrawer.svelte:35 (same as PersonChip), CorrespondentSuggestionsDropdown.svelte:43 (same as PersonChip). Edge case differences: CommentThread handles single-word names and multi-word names but NOT hyphenated first names (e.g., "Anna-Maria Raddatz" -> "AR", takes first char of each whitespace-separated word, max 2). PersonChip/MetadataDrawer/CorrespondentSuggestionsDropdown always take first char of firstName + first char of lastName -- will break on empty firstName or lastName. users/[id] gracefully handles missing names and returns null. The sharedgetInitialsneeds two signatures: one for(name: string)(CommentThread use case -- full name string) and one for(person: {firstName, lastName})(structured person). Or a singlegetInitials(name: string)and callers construct the string. Max 2 characters as Leonie suggests.isDirtyis manual tracking, not derived. All three admin pages (groups/[id]:8-9, tags/[id]:11-12, users/[id]:13-14) uselet isDirty = $state(false)toggled byoninputon the form. No dirty-checking against original values. This is acceptable for the hook extraction -- the hook will exposemarkDirty()(called fromoninput) and the form success effect resets it. Actual value-comparison dirty checking would be a separate enhancement.documents/[id]/+page.sveltenorenrich/[id]/+page.sveltecallsURL.revokeObjectURL(). This is a confirmed memory leak. Per project rules, the bug fix (adding revoke on cleanup and re-load) will be a separate commit before the hook extraction. The hook will own the full lifecycle: revoke previous URL before creating a new one, and revoke on component teardown.briefwechsel/+page.svelteis the only consumer. KISS beats DRY. Will remove from this issue's scope.$lib/hooks/naming convention confirmed..svelte.tsfor hooks using Svelte runes, plain.tsfor pure logic. Tests in__tests__/subdirectories.Re: Markus Keller
components -> hooks -> utils. Hooks may import from utils, never the reverse. Components import from both.Intl.DateTimeFormatcaching: Current code creates a new formatter per call. For document lists rendering 50-100 items, this is measurable but not critical. Will note as optimization point but not block this issue. If we cache, a simple module-levelMap<string, Intl.DateTimeFormat>keyed by${locale}-${format}suffices.Re: Sara Holt
relativeTimealready has tests innotifications.spec.ts(8 test cases covering boundary values, uses fixednowparameter). These tests will move totime.spec.ts. ThetimeAgovariant from CommentThread is untested -- new tests needed for the Paraglide-based version.beforeNavigatemock: Vitest test config has a browser project (svelte.{test,spec}.tspattern, runs in Chromium via Playwright). Hook tests with.svelte.test.tsextension will run in browser context where SvelteKit navigation can be mocked. Thenavigation.cancel()verification is testable.fetch,URL.createObjectURL,URL.revokeObjectURL. Will verify revoke on both cleanup and re-load.Re: Nora "NullX" Steiner
revokeObjectURL. Will fix as separate commit. On the Content-Type concern: theDocumentViewer(DocumentViewer.svelte:85-104) renders blob URLs only as<img>tags or passes them toPdfViewer(canvas-based rendering via pdfjs-dist). No<iframe>or<object>tags are used, so the XSS-via-text/html vector does not apply. Error messages are hardcoded user-friendly strings ("Nicht eingeloggt", "Fehler beim Laden der Datei",m.doc_file_error_preview()) -- no server details leak.discardTargetopen redirect:discardTargetis set fromto?.url.hrefin thebeforeNavigatecallback (groups/[id]:13, tags/[id]:16, users/[id]:18). Thetoobject comes from SvelteKit's navigation lifecycle -- it represents the URL the user clicked within the app. It is not user-controlled input from query params. SvelteKit'sgoto()is client-side only. No open redirect risk.briefwechsel/+page.svelte:70usesnew SvelteURLSearchParams()with.set()calls -- encoding is handled by the URLSearchParams API. No manual string construction. Safe.Re: Leonie Voss
mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300. Same i18n keys:m.admin_unsaved_warning()for text,m.person_discard_changes()for discard button. Extraction will produce a pixel-identical component. All text uses Paraglide translation keys, not hardcoded German.relativeTimeoutput format: As noted above, the two implementations produce different output. The consolidated version will keep the Paraglide-based output (translated strings), matching what CommentThread users currently see. Notification consumers currently seeIntl.RelativeTimeFormatoutput -- this will change to the Paraglide format. This is a minor visual change in notifications that improves i18n consistency.date.tsformatDatepasses no format arg (= long). Every consumer ofpersonFormat.tsformatDatepasses an explicit format arg. After consolidation with'long'as default, all existing call sites produce identical output. No visual regression.Decisions
$lib/utils/time.tsdate.tswith optional format parampersonFormat.ts, max 2 charsUpdated Acceptance Criteria
npm run checkpasses$lib/hooks/convention:.svelte.tsfor rune-dependent hooks,.tsfor pure logic