Repository: libris/lxlviewer
Pull request #1291: Display links to things in local systems
Author: login: lrosenstrom
Created: 2025-05-09T08:55:47Z | Merged: 2025-05-27T07:34:18Z

## Description

### Tickets involved
[LWS-249](https://kbse.atlassian.net/browse/LWS-249)

### Solves

The goal is to provide access to items in the local library catalog as well as additional library info such as address, opening hours etc. This is currently presented in the holding panel:

![Screenshot from 2025-05-22 09-17-55](https://github.com/user-attachments/assets/4b39964a-bceb-44d6-8397-0fd55da0e5c2)


### Summary of changes

* Construct a mapping for library code (sigel) to the links/info on fnurgel page load 
* Tried to keep close to the Figma sketches for the frontend part
* Add a basic server side caching mechanism for full holder/library data. There is plenty of information about caching in the browser in a SvelteKit context but not so much about caching on the server. The approach used in this PR comes from https://www.loopwerk.io/articles/2025/svelte-5-stores/, which should be fine since the data we are caching is not tied to a specific user/session/browser. I think introducing an in-memory database such as Redis is overkill for this, but that's what most people seem to be suggesting in the forums.









Diff:
diff --git a/lxl-web/src/app.css b/lxl-web/src/app.css
index d9a56bdbcb..3c5f78d70a 100644
--- a/lxl-web/src/app.css
+++ b/lxl-web/src/app.css
@@ -245,6 +245,15 @@
 		}
 	}
 
+	.btn-outlined {
+		color: var(--color-link);
+		font-size: var(--text-xs);
+		padding: calc(var(--spacing) * 1.5) calc(var(--spacing) * 6);
+		height: calc(var(--spacing) * 10);
+		border-radius: calc(infinity * 1px);
+		border: 1px solid var(--color-link);
+	}
+
 	.badge {
 		display: flex;
 		align-items: center;
diff --git a/lxl-web/src/lib/assets/json/display-web.json b/lxl-web/src/lib/assets/json/display-web.json
index 5758cb2460..fc4c552c8c 100644
--- a/lxl-web/src/lib/assets/json/display-web.json
+++ b/lxl-web/src/lib/assets/json/display-web.json
@@ -234,6 +234,18 @@
 				}
 			}
 		},
+		"tokens": {
+			"@id": "tokens",
+			"@type": "fresnel:Group",
+			"lenses": {
+				"Publication": {
+					"@id": "Publication-tokens",
+					"@type": "fresnel:Lens",
+					"classLensDomain": "Publication",
+					"showProperties": ["agent", "year"]
+				}
+			}
+		},
 		"web-chips": {
 			"@id": "web-chips",
 			"@type": "fresnel:Group",
@@ -823,6 +835,26 @@
 				"fresnel:contentBefore": ""
 			}
 		},
+		"bibdb:PostalAddress-format": {
+			"@id": "bibdb:PostalAddress-format",
+			"@type": "fresnel:Format",
+			"fresnel:classFormatDomain": ["bibdb:PostalAddress"],
+			"fresnel:propertyFormatDomain": ["bibdb:streetAddress", "bibdb:postalCode", "bibdb:email"],
+			"fresnel:propertyFormat": {
+				"fresnel:contentBefore": "\n",
+				"fresnel:contentFirst": ""
+			}
+		},
+		"bibdb:PostalAddress-format2": {
+			"@id": "bibdb:PostalAddress-format2",
+			"@type": "fresnel:Format",
+			"fresnel:classFormatDomain": ["bibdb:PostalAddress"],
+			"fresnel:propertyFormatDomain": ["bibdb:addressLocality"],
+			"fresnel:propertyFormat": {
+				"fresnel:contentBefore": " ",
+				"fresnel:contentFirst": ""
+			}
+		},
 		"default-separators": {
 			"@id": "default-separators",
 			"@type": "fresnel:Format",
diff --git a/lxl-web/src/lib/i18n/locales/en.js b/lxl-web/src/lib/i18n/locales/en.js
index 99a9422f19..5f64f57033 100644
--- a/lxl-web/src/lib/i18n/locales/en.js
+++ b/lxl-web/src/lib/i18n/locales/en.js
@@ -193,7 +193,12 @@ export default {
 		loanStatusFailed: 'Failed to get loan status',
 		available: 'Available',
 		unavailable: 'Not available',
-		map: 'map'
+		map: 'map',
+		linkToLocal: 'Show in local library catalog',
+		loanReserveLink: 'Loan/reserve',
+		linkToCatalog: 'Local library catalog',
+		linkToSite: 'Library website',
+		openingHoursEtc: 'Opening hours, address etc'
 	},
 	filterAlias: {
 		'alias-myLibraries': 'My Libraries'
diff --git a/lxl-web/src/lib/i18n/locales/sv.js b/lxl-web/src/lib/i18n/locales/sv.js
index 215e689b62..7afe1bc145 100644
--- a/lxl-web/src/lib/i18n/locales/sv.js
+++ b/lxl-web/src/lib/i18n/locales/sv.js
@@ -192,7 +192,12 @@ export default {
 		loanStatusFailed: 'Lånestatus kunde inte hämtas',
 		available: 'Tillgänglig',
 		unavailable: 'Ej tillgänglig',
-		map: 'karta'
+		map: 'karta',
+		linkToLocal: 'Visa i bibliotekets katalog',
+		loanReserveLink: 'Låna/reservera',
+		linkToCatalog: 'Bibliotekets lokala katalog',
+		linkToSite: 'Bibliotekets webbplats',
+		openingHoursEtc: 'Öppettider, adress m.m.'
 	},
 	filterAlias: {
 		'alias-myLibraries': 'Mina bibliotek'
diff --git a/lxl-web/src/lib/types/holdings.ts b/lxl-web/src/lib/types/holdings.ts
index 3ef5240b1c..017bc20c2e 100644
--- a/lxl-web/src/lib/types/holdings.ts
+++ b/lxl-web/src/lib/types/holdings.ts
@@ -1,4 +1,4 @@
-import type { DisplayDecorated } from './xl';
+import type { DisplayDecorated, FramedData } from './xl';
 
 export type BibIdObj = {
 	bibId: string;
@@ -7,6 +7,7 @@ export type BibIdObj = {
 	onr: string | null;
 	isbn: string[];
 	issn: string[];
+	str: string;
 };
 
 export type HoldingsByInstanceId = {
@@ -29,3 +30,15 @@ export type DecoratedHolder = {
 	sigel: string;
 	str: string;
 };
+
+export type FullHolderBySigel = {
+	[sigel: string]: FramedData;
+};
+
+export type ItemLinksForHolder = {
+	[sigel: string]: { [linkType: string]: string[] };
+};
+
+export type ItemLinksByBibId = {
+	[id: string]: ItemLinksForHolder;
+};
diff --git a/lxl-web/src/lib/types/xl.ts b/lxl-web/src/lib/types/xl.ts
index 09f628a5f2..91219f7be2 100644
--- a/lxl-web/src/lib/types/xl.ts
+++ b/lxl-web/src/lib/types/xl.ts
@@ -46,6 +46,28 @@ export enum Platform {
 	meta = 'meta'
 }
 
+export enum BibDb {
+	ils = 'bibdb:ils',
+	lopac = 'bibdb:lopac',
+	bibIdSearchUriByLang = 'bibdb:bibIdSearchUriByLang',
+	bibIdSearchUri = 'bibdb:bibIdSearchUri',
+	isbnSearchUri = 'bibdb:isbnSearchUri',
+	issnSearchUri = 'bibdb:issnSearchUri',
+	eodUri = 'bibdb:eodUri',
+	itemStatusUri = 'bibdb:itemStatusUri',
+	openingHours = 'bibdb:openingHours',
+	address = 'bibdb:address',
+	postalAddress = 'bibdb:PostalAddress',
+	visitingAddress = 'bibdb:VisitingAddress',
+	LinksToCatalog = 'linksToCatalog',
+	LinksToSite = 'linksToSite',
+	LinksToItem = 'linksToItem',
+	Address = 'address',
+	ItemStatus = 'itemStatus',
+	OpeningHours = 'openingHours',
+	LoanReserveLink = 'loanReserveLink'
+}
+
 export type ClassName = string;
 export type PropertyName = string;
 export type LangCode = string;
diff --git a/lxl-web/src/lib/utils/holdersCache.svelte.ts b/lxl-web/src/lib/utils/holdersCache.svelte.ts
new file mode 100644
index 0000000000..3961f4cbc8
--- /dev/null
+++ b/lxl-web/src/lib/utils/holdersCache.svelte.ts
@@ -0,0 +1,6 @@
+import type { FullHolderBySigel } from '$lib/types/holdings';
+
+type Cache = {
+	holders: FullHolderBySigel;
+};
+export const holdersCache: Cache = $state({ holders: {} });
diff --git a/lxl-web/src/lib/utils/holdings.test.ts b/lxl-web/src/lib/utils/holdings.test.ts
index b7d25c05bb..f61c9547cc 100644
--- a/lxl-web/src/lib/utils/holdings.test.ts
+++ b/lxl-web/src/lib/utils/holdings.test.ts
@@ -6,14 +6,18 @@ import { UserSettings } from './userSettings.svelte';
 
 describe('getBibIdsByInstanceId', () => {
 	it('Returns a correctly mapped object (bibId, type & holders)', () => {
-		expect(getBibIdsByInstanceId(mainEntity, record)).toStrictEqual({
+		const instanceTokenStr = 'Natur och kultur, 2018';
+		const DisplayUtil = { lensAndFormat: () => instanceTokenStr };
+
+		expect(getBibIdsByInstanceId(mainEntity, DisplayUtil, record, 'sv')).toStrictEqual({
 			'0h96fs3b0c49qkt': {
 				bibId: '7654300',
 				'@type': 'Instance',
 				holders: ['S', 'H', 'U', 'Um', 'Umdp', 'La', 'Q', 'L', 'Sbi', 'NB'],
 				onr: '9176423484',
 				isbn: ['9176423484'],
-				issn: []
+				issn: [],
+				str: instanceTokenStr
 			}
 		});
 	});
diff --git a/lxl-web/src/lib/utils/holdings.ts b/lxl-web/src/lib/utils/holdings.ts
index aecdb68722..709f0ebc9d 100644
--- a/lxl-web/src/lib/utils/holdings.ts
+++ b/lxl-web/src/lib/utils/holdings.ts
@@ -1,11 +1,19 @@
 import { pushState } from '$app/navigation';
 import isFnurgel from '$lib/utils/isFnurgel';
-import type { BibIdObj, HoldersByType, HoldingsByInstanceId } from '$lib/types/holdings';
-import { LensType, type FramedData } from '$lib/types/xl';
+import type {
+	BibIdObj,
+	HoldersByType,
+	HoldingsByInstanceId,
+	ItemLinksByBibId,
+	ItemLinksForHolder
+} from '$lib/types/holdings';
+import { LensType, type FramedData, JsonLd, BibDb } from '$lib/types/xl';
 import type { LocaleCode } from '$lib/i18n/locales';
 import type { LibraryItem, UserSettings } from '$lib/types/userSettings';
 import { relativizeUrl } from '$lib/utils/http';
 import { DisplayUtil, toString } from '$lib/utils/xl.js';
+import getAtPath from '$lib/utils/getAtPath';
+import { holdersCache } from '$lib/utils/holdersCache.svelte';
 
 export function getHoldingsLink(url: URL, value: string) {
 	const newSearchParams = new URLSearchParams([...Array.from(url.searchParams.entries())]);
@@ -72,13 +80,24 @@ export function getHoldingsByInstanceId(
 	}, {});
 }
 
-export function getBibIdsByInstanceId(mainEntity, record): Record<string, BibIdObj> {
-	return mainEntity['@reverse']?.instanceOf?.reduce((acc, instanceOfItem) => {
-		const id = relativizeUrl(instanceOfItem['@id'])?.replace('#it', '');
+export function getBibIdsByInstanceId(
+	mainEntity,
+	displayUtil: DisplayUtil,
+	record,
+	locale: LocaleCode
+): Record<string, BibIdObj> {
+	return mainEntity['@reverse']?.instanceOf?.reduce((acc, instance) => {
+		const id = relativizeUrl(instance['@id'])?.replace('#it', '');
 
-		const bibId = instanceOfItem.meta?.controlNumber || record?.controlNumber;
-		const type = instanceOfItem['@type'];
-		const holders = instanceOfItem['@reverse']?.itemOf?.map((i) => i?.heldBy?.sigel);
+		const bibId = instance.meta?.controlNumber || record?.controlNumber;
+		const type = instance['@type'];
+		const holders = instance['@reverse']?.itemOf?.map((i) => i?.heldBy?.sigel);
+		const publication = instance.publication;
+		let str = '';
+		if (publication) {
+			str =
+				toString(displayUtil.lensAndFormat(instance.publication[0], LensType.Token, locale)) || '';
+		}
 
 		// add Legacy Libris III system number for ONR param
 		let onr = null;
@@ -90,7 +109,7 @@ export function getBibIdsByInstanceId(mainEntity, record): Record<string, BibIdO
 
 		const isbn: string[] = [];
 		const issn: string[] = [];
-		instanceOfItem.identifiedBy?.forEach((el: { '@type': string; value: string }) => {
+		instance.identifiedBy?.forEach((el: { '@type': string; value: string }) => {
 			if (el['@type'] === 'ISBN') {
 				isbn.push(el.value);
 			}
@@ -111,7 +130,8 @@ export function getBibIdsByInstanceId(mainEntity, record): Record<string, BibIdO
 				holders,
 				onr,
 				isbn,
-				issn
+				issn,
+				str
 			}
 		};
 	}, {});
@@ -185,3 +205,145 @@ export function getMyLibsFromHoldings(
 	}
 	return Object.values(result);
 }
+
+export async function fetchHoldersIfAbsent(holdersByType: HoldersByType) {
+	const cachedHolders = holdersCache.holders;
+	const allHolders = Object.values(holdersByType).flat();
+	for (const h of allHolders) {
+		const id = h.obj?.['@id'];
+
+		if (h.sigel && cachedHolders && !cachedHolders[h.sigel]) {
+			const response = await fetch(`${id}?framed=true`, {
+				headers: { Accept: 'application/ld+json' }
+			});
+			if (response.ok) {
+				const resJson = await response.json();
+				const libraryMainEntity = resJson['mainEntity'] as FramedData;
+				if (libraryMainEntity) {
+					cachedHolders[h.sigel] = libraryMainEntity;
+				}
+			} else {
+				console.error(`Could not fetch holder data for ${id}`);
+			}
+		}
+	}
+}
+
+export function getItemLinksByBibId(
+	bibIdsByInstanceId: Record<string, BibIdObj>,
+	locale: LocaleCode,
+	displayUtil: DisplayUtil
+): ItemLinksByBibId {
+	const linksByInstanceId: ItemLinksByBibId = {};
+	for (const bibIdObj of Object.values(bibIdsByInstanceId || [])) {
+		const linksForHolder: ItemLinksForHolder = {};
+		bibIdObj.holders?.forEach((sigel) => {
+			if (holdersCache.holders) {
+				const fullHolderData = holdersCache.holders[sigel];
+
+				const ilsPaths = [
+					[BibDb.ils, BibDb.bibIdSearchUri],
+					[BibDb.ils, BibDb.isbnSearchUri],
+					[BibDb.ils, BibDb.issnSearchUri]
+				];
+
+				const lopacPaths = [[BibDb.lopac, BibDb.bibIdSearchUriByLang]];
+
+				let linksToItem = getLinksToItemFor(bibIdObj, fullHolderData, ilsPaths, locale);
+				const lopacLinksItem = getLinksToItemFor(bibIdObj, fullHolderData, lopacPaths, locale);
+
+				const linkTemplateEod = getAtPath(fullHolderData, [BibDb.eodUri], []);
+				if (linkTemplateEod && linkTemplateEod.length !== 0) {
+					linksToItem = [linkTemplateEod.replace(/%BIB_*ID%/, bibIdObj.bibId), ...linksToItem];
+				}
+
+				//TODO: rename
+				const allLinks: { [linkType: string]: string[] } = {};
+
+				const itemStatusUri = getAtPath(fullHolderData, [BibDb.ils, BibDb.itemStatusUri], []);
+
+				if (itemStatusUri && itemStatusUri.length !== 0) {
+					allLinks[BibDb.ItemStatus] = [itemStatusUri];
+				}
+
+				const linksToCatalog: string[] = [];
+				const linkToCatalog = getAtPath(fullHolderData, [BibDb.ils, 'url'], undefined);
+				if (linkToCatalog && linkToCatalog.length !== 0) {
+					linksToCatalog.push(linkToCatalog);
+					allLinks[BibDb.LinksToCatalog] = linksToCatalog;
+				}
+
+				const linksToSite: string[] = [];
+				const linkToSite = getAtPath(fullHolderData, ['url', JsonLd.ID], undefined);
+				if (linkToSite && linkToSite.length !== 0) {
+					linksToSite.push(linkToSite);
+					allLinks[BibDb.LinksToSite] = linksToSite;
+				}
+
+				const openingHoursList: string[] = [];
+				const openingHours = getAtPath(fullHolderData, [BibDb.openingHours], undefined);
+				if (openingHours && openingHours !== '') {
+					openingHoursList.push(openingHours);
+					allLinks[BibDb.OpeningHours] = openingHoursList;
+				}
+
+				const addresses: string[] = [];
+				const address = getAtPath(fullHolderData, [BibDb.address, '*'], undefined);
+				const postalAddress = address.find((a) => a[JsonLd.TYPE] === BibDb.postalAddress);
+				const visitingAddress = address.find((a) => a[JsonLd.TYPE] === BibDb.visitingAddress);
+
+				if (address && address.length !== 0) {
+					addresses.push(
+						toString(displayUtil.lensAndFormat(visitingAddress, LensType.Card, locale)) || ''
+					);
+					addresses.push(
+						toString(displayUtil.lensAndFormat(postalAddress, LensType.Card, locale)) || ''
+					);
+					allLinks[BibDb.Address] = addresses;
+				}
+
+				if (linksToItem.length !== 0) {
+					allLinks[BibDb.LinksToItem] = linksToItem;
+				}
+
+				if (lopacLinksItem.length !== 0) {
+					allLinks[BibDb.LoanReserveLink] = lopacLinksItem;
+				}
+
+				if (Object.keys(allLinks).length !== 0) {
+					linksForHolder[sigel] = allLinks;
+				}
+			}
+		});
+		linksByInstanceId[bibIdObj.bibId] = linksForHolder;
+	}
+	return linksByInstanceId;
+}
+
+function getLinksToItemFor(
+	bibIdObj: BibIdObj,
+	fullHolderData: FramedData,
+	paths: string[][],
+	locale: LocaleCode
+): string[] {
+	let linksToItem: string[] = [];
+	for (const path of paths) {
+		const linkTemplate = getAtPath(fullHolderData, path, []);
+		if (linkTemplate && linkTemplate.length !== 0) {
+			if (path.includes(BibDb.bibIdSearchUriByLang) && bibIdObj.bibId !== '') {
+				linksToItem = [linkTemplate[locale].replace(/%BIB_*ID%/g, bibIdObj.bibId), ...linksToItem];
+			}
+			if (path.includes(BibDb.bibIdSearchUri) && bibIdObj.bibId !== '') {
+				// forms in the wild %BIB_ID%, %BIBID%, more???
+				linksToItem = [linkTemplate.replace(/%BIB_*ID%/g, bibIdObj.bibId), ...linksToItem];
+			}
+			if (path.includes(BibDb.isbnSearchUri) && bibIdObj.isbn.length !== 0) {
+				linksToItem = [linkTemplate.replace(/%ISBN%/g, bibIdObj.isbn), ...linksToItem];
+			}
+			if (path.includes(BibDb.issnSearchUri) && bibIdObj.issn.length !== 0) {
+				linksToItem = [linkTemplate.replace(/%ISSN%/g, bibIdObj.issn), ...linksToItem];
+			}
+		}
+	}
+	return linksToItem;
+}
diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.server.ts b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.server.ts
index 0a1014f61e..d0c258554e 100644
--- a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.server.ts
+++ b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.server.ts
@@ -18,9 +18,12 @@ import {
 	getHoldingsByInstanceId,
 	getHoldingsByType,
 	getHoldersByType,
-	getBibIdsByInstanceId
+	getBibIdsByInstanceId,
+	getItemLinksByBibId,
+	fetchHoldersIfAbsent
 } from '$lib/utils/holdings.js';
 import { DebugFlags } from '$lib/types/userSettings';
+import { holdersCache } from '$lib/utils/holdersCache.svelte.js';
 
 export const load = async ({ params, url, locals, fetch }) => {
 	const displayUtil = locals.display;
@@ -68,10 +71,18 @@ export const load = async ({ params, url, locals, fetch }) => {
 
 	const images = getImages(mainEntity, locale).map((i) => toSecure(i, env.AUXD_SECRET));
 	const holdingsByInstanceId = getHoldingsByInstanceId(mainEntity, displayUtil, locale);
-	const bibIdsByInstanceId = getBibIdsByInstanceId(mainEntity, resource);
+	const bibIdsByInstanceId = getBibIdsByInstanceId(mainEntity, displayUtil, resource, locale);
 	const holdingsByType = getHoldingsByType(mainEntity);
 	const holdersByType = getHoldersByType(holdingsByType, displayUtil, locale);
 
+	await fetchHoldersIfAbsent(holdersByType);
+
+	if (holdersCache.holders) {
+		console.log('Current number of cached holders:', Object.keys(holdersCache.holders).length);
+	}
+
+	const itemLinksByBibId = getItemLinksByBibId(bibIdsByInstanceId, locale, displayUtil);
+
 	return {
 		type: mainEntity[JsonLd.TYPE],
 		title: toString(heading),
@@ -82,6 +93,7 @@ export const load = async ({ params, url, locals, fetch }) => {
 		holdingsByInstanceId,
 		bibIdsByInstanceId,
 		holdersByType,
+		itemLinksByBibId: itemLinksByBibId,
 		full: overview,
 		images,
 		searchResult: searchPromise ? await searchPromise : null
diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.svelte b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.svelte
index ff8b2b0f0d..190e16c06c 100644
--- a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.svelte
+++ b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.svelte
@@ -14,7 +14,7 @@
 	import { getResourceId } from '$lib/utils/resourceData';
 
 	import InstancesList from './InstancesList.svelte';
-	import HoldingStatus from './HoldingStatus.svelte';
+	import HoldingInfo from './HoldingInfo.svelte';
 	import Modal from '$lib/components/Modal.svelte';
 	import ResourceImage from '$lib/components/ResourceImage.svelte';
 	import DecoratedData from '$lib/components/DecoratedData.svelte';
@@ -290,7 +290,7 @@
 							</h3>
 							<ul class="w-full">
 								{#each myLibsHolders as holder, i (holder.sigel || i)}
-									<HoldingStatus {holder} {holdingUrl} />
+									<HoldingInfo {holder} {holdingUrl} linksByBibId={data.itemLinksByBibId} />
 								{/each}
 							</ul>
 						</div>
@@ -307,7 +307,7 @@
 					</div>
 					<ul class="w-full">
 						{#each filteredHolders as holder, i (holder.sigel || i)}
-							<HoldingStatus {holder} {holdingUrl} />
+							<HoldingInfo {holder} {holdingUrl} linksByBibId={data.itemLinksByBibId} />
 						{/each}
 						{#if filteredHolders.length === 0}
 							<li class="m-3">
diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/HoldingStatus.svelte b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/HoldingAvailability.svelte
similarity index 82%
rename from lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/HoldingStatus.svelte
rename to lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/HoldingAvailability.svelte
index 88b9f68bd7..8979438810 100644
--- a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/HoldingStatus.svelte
+++ b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/HoldingAvailability.svelte
@@ -2,32 +2,20 @@
 	import { page } from '$app/state';
 	import { type HoldingStatus } from '$lib/types/api';
 	import type { BibIdObj, DecoratedHolder } from '$lib/types/holdings';
-	import { ShowLabelsOptions } from '$lib/types/decoratedData';
-	import DecoratedData from '$lib/components/DecoratedData.svelte';
 	import BiChevronRight from '~icons/bi/chevron-right';
 
-	type HoldingStatusProps = {
+	type HoldingAvailabilityProps = {
 		holder: DecoratedHolder;
-		holdingUrl: string;
+		bibId: BibIdObj;
 	};
 
-	const { holder, holdingUrl }: HoldingStatusProps = $props();
+	const { holder, bibId }: HoldingAvailabilityProps = $props();
 
 	const sigel = holder?.sigel;
 	let loading = $state(false);
 	let statusData: HoldingStatus[] | undefined = $state();
 	let error: string | undefined = $state();
-
-	// if holdingUrl is an instance fnurgel, add its mapped bibId object into arr,
-	// else add all ids of current type with holdings for current sigel
-	const bibIds = $derived.by(() => {
-		return page.data.bibIdsByInstanceId?.[holdingUrl]
-			? [page.data.bibIdsByInstanceId[holdingUrl]]
-			: Object.keys(page.data.bibIdsByInstanceId)
-					.filter((i) => page.data.bibIdsByInstanceId[i]['@type'] === holdingUrl)
-					.filter((i) => page.data.bibIdsByInstanceId[i].holders?.includes(sigel))
-					.map((i) => page.data.bibIdsByInstanceId[i]);
-	});
+	let bibIds: BibIdObj[] = [bibId];
 
 	async function fetchHoldingStatus(ids: BibIdObj[]) {
 		const promises = ids.map((id) => {
@@ -109,22 +97,20 @@
 	}
 </script>
 
-<li class="border-neutral text-xs not-last:border-b">
+<div class="my-2">
 	<details ontoggle={getHoldingStatus}>
-		<summary class="my-3 flex cursor-pointer items-baseline">
+		<summary class="flex cursor-pointer items-baseline">
 			<span class="arrow text-subtle mr-2 h-3 origin-center rotate-0 transition-transform">
 				<BiChevronRight />
 			</span>
-			<span class="holder-label">
-				<DecoratedData data={holder.obj} showLabels={ShowLabelsOptions['Never']} />
-			</span>
+			Tillgänglighet
 		</summary>
 		<div class="mb-4 flex flex-col gap-2">
 			{#if loading}
 				<p>{page.data.t('search.loading')}</p>
 			{/if}
 			{#if error}
-				<div class="status-container border-neutral bg-page max-w-md rounded-sm border p-2">
+				<div class="status-container border-neutral bg-page mt-2 max-w-md rounded-sm border p-2">
 					<p class="error" role="alert">{error}</p>
 				</div>
 			{/if}
@@ -133,7 +119,7 @@
 					{#if instance?.item_information}
 						{@const items = instance.item_information}
 						<div
-							class="status-container border-neutral bg-page flex max-w-md flex-col gap-4 rounded-sm border p-2"
+							class="status-container border-neutral bg-page mt-2 flex max-w-md flex-col gap-4 rounded-sm border p-2"
 						>
 							{#if items.error || items.count === 0}
 								{#if urlNotDefinedError(items.error)}
@@ -192,7 +178,7 @@
 			{/if}
 		</div>
 	</details>
-</li>
+</div>
 
 <style lang="postcss">
 	@reference "../../../../app.css";
@@ -209,6 +195,7 @@
 
 	.holder-label {
 		@apply flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
+		font-weight: bold;
 	}
 
 	.status-container {
@@ -233,6 +220,10 @@
 		width: auto;
 	}
 
+	.instance-token {
+		color: var(--color-subtle);
+	}
+
 	.indicator {
 		@apply mb-0.5 inline-block h-2.5 w-2.5 rounded-full align-middle;
 
diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/HoldingInfo.svelte b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/HoldingInfo.svelte
new file mode 100644
index 0000000000..ad2183ac17
--- /dev/null
+++ b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/HoldingInfo.svelte
@@ -0,0 +1,207 @@
+<script lang="ts">
+	import { page } from '$app/state';
+	import type { DecoratedHolder, ItemLinksByBibId } from '$lib/types/holdings';
+	import { ShowLabelsOptions } from '$lib/types/decoratedData';
+	import DecoratedData from '$lib/components/DecoratedData.svelte';
+	import BiChevronRight from '~icons/bi/chevron-right';
+	import { BibDb } from '$lib/types/xl';
+	import HoldingAvailability from './HoldingAvailability.svelte';
+
+	type HoldingInfoProps = {
+		holder: DecoratedHolder;
+		holdingUrl: string;
+		linksByBibId: ItemLinksByBibId;
+	};
+
+	const { holder, holdingUrl, linksByBibId }: HoldingInfoProps = $props();
+
+	const sigel = holder?.sigel;
+
+	// if holdingUrl is an instance fnurgel, add its mapped bibId object into arr,
+	// else add all ids of current type with holdings for current sigel
+	const bibIds = $derived.by(() => {
+		return page.data.bibIdsByInstanceId?.[holdingUrl]
+			? [page.data.bibIdsByInstanceId[holdingUrl]]
+			: Object.keys(page.data.bibIdsByInstanceId)
+					.filter((i) => page.data.bibIdsByInstanceId[i]['@type'] === holdingUrl)
+					.filter((i) => page.data.bibIdsByInstanceId[i].holders?.includes(sigel))
+					.map((i) => page.data.bibIdsByInstanceId[i]);
+	});
+
+	function hasLinksToItem(id: string) {
+		return linksByBibId[id]?.[holder.sigel]?.[BibDb.LinksToItem];
+	}
+
+	function hasLoanReserveLink(id: string) {
+		return linksByBibId[id]?.[holder.sigel]?.[BibDb.LoanReserveLink];
+	}
+
+	function missingAtLeastOneLinkToItem() {
+		return bibIds.find((id) => !hasLinksToItem(id.bibId) && !hasLoanReserveLink(id.bibId));
+	}
+
+	function hasStatusLink(id: string) {
+		return linksByBibId[id][holder.sigel][BibDb.ItemStatus];
+	}
+
+	function hasInstanceContent(id: string) {
+		return hasLoanReserveLink(id) || hasLinksToItem(id) || hasStatusLink(id);
+	}
+</script>
+
+<li class="border-neutral text-xs not-last:border-b">
+	<div class="holder-label mt-4">
+		<DecoratedData data={holder.obj} showLabels={ShowLabelsOptions['Never']} />
+	</div>
+	<div class="ml-3">
+		<div class="my-3">
+			<ul>
+				{#each bibIds as id (id.bibId)}
+					{#if linksByBibId[id.bibId]?.[holder.sigel]}
+						{@const linksForHolder = linksByBibId[id.bibId][holder.sigel]}
+						{#if hasInstanceContent(id.bibId)}
+							<div class="instance-token mt-4 mb-1">{id.str || '-'}</div>
+							{#if hasStatusLink(id.bibId)}
+								<HoldingAvailability {holder} bibId={id}></HoldingAvailability>
+							{/if}
+							<div class="mb-3">
+								{#if hasLoanReserveLink(id.bibId)}
+									<li>
+										<a
+											href={linksForHolder['loanReserveLink'].at(0)}
+											target="_blank"
+											class="btn btn-outlined ext-link h-9 max-w-sm"
+										>
+											{page.data.t('holdings.loanReserveLink')}
+										</a>
+									</li>
+								{:else if hasLinksToItem(id.bibId)}
+									<li>
+										<a
+											href={linksForHolder[BibDb.LinksToItem].at(0)}
+											target="_blank"
+											class="btn btn-outlined ext-link h-9 max-w-sm"
+										>
+											{page.data.t('holdings.linkToLocal')}
+										</a>
+									</li>
+								{/if}
+							</div>
+						{/if}
+					{/if}
+				{/each}
+				{#if bibIds.at(0) && missingAtLeastOneLinkToItem()}
+					{@const firstBibId = bibIds.at(0).bibId}
+					<div class="mt-2">
+						{#if linksByBibId[firstBibId]?.[holder.sigel]?.[BibDb.LinksToCatalog]}
+							<li>
+								<a
+									href={linksByBibId[firstBibId][holder.sigel][BibDb.LinksToCatalog].at(0)}
+									target="_blank"
+									class="ext-link"
+								>
+									{page.data.t('holdings.linkToCatalog')}
+								</a>
+							</li>
+						{:else if linksByBibId[firstBibId]?.[holder.sigel]?.[BibDb.LinksToSite]}
+							<li>
+								<a
+									href={linksByBibId[firstBibId][holder.sigel][BibDb.LinksToSite].at(0)}
+									target="_blank"
+									class="ext-link"
+								>
+									{page.data.t('holdings.linkToSite')}
+								</a>
+							</li>
+						{/if}
+					</div>
+				{/if}
+			</ul>
+		</div>
+		<details>
+			<summary class="my-4 flex cursor-pointer items-baseline">
+				<span class="arrow text-subtle mr-2 h-3 origin-center rotate-0 transition-transform">
+					<BiChevronRight />
+				</span>
+				{page.data.t('holdings.openingHoursEtc')}
+			</summary>
+			<div class="status-container border-neutral bg-page my-3 max-w-md rounded-sm border p-2">
+				<span>
+					{#if bibIds.at(0)}
+						{@const firstBibId = bibIds.at(0).bibId}
+						<ul style="white-space: pre-line">
+							{#if linksByBibId[firstBibId]?.[holder.sigel]?.[BibDb.OpeningHours]}
+								<li>
+									{linksByBibId[firstBibId][holder.sigel][BibDb.OpeningHours].at(0)}
+								</li>
+							{/if}
+							{#if linksByBibId[firstBibId]?.[holder.sigel]?.[BibDb.Address]}
+								{#each linksByBibId[firstBibId][holder.sigel][BibDb.Address] as address, index (index)}
+									<li class="my-2">
+										{address}
+									</li>
+								{/each}
+							{/if}
+						</ul>
+					{/if}
+				</span>
+			</div>
+		</details>
+	</div>
+</li>
+
+<style lang="postcss">
+	@reference "../../../../app.css";
+
+	details[open] {
+		& .arrow {
+			@apply rotate-90;
+		}
+
+		& .holder-label {
+			white-space: normal;
+		}
+	}
+
+	.holder-label {
+		@apply flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
+		font-weight: bold;
+	}
+
+	.status-container {
+		&:has(p.error) {
+			background-color: var(--color-warning-100);
+		}
+
+		/* don't repeat the library-unavailable message for every instance */
+		&:has(p.library-unavailable):not(:first-of-type) {
+			display: none;
+		}
+	}
+
+	table th {
+		font-weight: var(--font-weight-medium);
+		width: calc(var(--spacing) * 24);
+		padding-right: calc(var(--spacing) * 4);
+		vertical-align: baseline;
+	}
+
+	table td {
+		width: auto;
+	}
+
+	.instance-token {
+		color: var(--color-subtle);
+	}
+
+	.indicator {
+		@apply mb-0.5 inline-block h-2.5 w-2.5 rounded-full align-middle;
+
+		&.unavailable {
+			background-color: var(--color-severe-500);
+		}
+		&.available {
+			background-color: var(--color-success);
+		}
+	}
+</style>


Reviews:
user: login: johanbissemattsson
body: LGTM! 🙌  

We probably want some kind of support for refreshed cache data in the future (so updated libraries data will be reflected in the cache without having to restart the server) but this can maybe be saved for later? I think it would be good to have a comment or issue created regarding this if the question arises.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T11:27:50Z
user: login: olovy
body: LGTM!

See comments.

As discussed offline:
* The key for holders/libraries should be `@id` not sigel
* records should be referenced by `@id`, not controlNumber. controlNumber is a MARC identifier and can contain old numeric Libris IDs. If systemIds/fnurgels are preferred because they are shorter in the URL they should be constructed by removing the system base URI prefix.

This behavior hasn't changed in this PR and can be fixed later.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T11:54:08Z
user: login: jesperengstrom
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T12:02:40Z
user: login: lrosenstrom
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T12:08:07Z

Review comments:
diff_hunk: @@ -185,3 +203,163 @@ export function getMyLibsFromHoldings(
 	}
 	return Object.values(result);
 }
+
+export async function getFullHolderData(allHolders: DecoratedHolder[]): Promise<FullHolderBySigel> {
+	const holderBySigel: FullHolderBySigel = {};
+
+	for (const h of allHolders) {
+		const id = h.obj?.['@id'];
+		const libraryRes = await fetch(`${id}?framed=true`, {
+			headers: { Accept: 'application/ld+json' }
+		});
+		const resJson = await libraryRes.json();
+		const libraryMainEntity = resJson['mainEntity'] as FramedData;
+
+		if (libraryMainEntity) {
+			holderBySigel[h.sigel] = libraryMainEntity;
+		}
+	}
+	return holderBySigel;
+}
+
+export async function fetchHoldersIfAbsent(holdersByType: HoldersByType) {
+	const cachedHolders = holdersCache.holders;
+	const allHolders = Object.values(holdersByType).flat();
+	for (const h of allHolders) {
+		const id = h.obj?.['@id'];
+
+		if (h.sigel && cachedHolders && !cachedHolders[h.sigel]) {
+			const response = await fetch(`${id}?framed=true`, {
+				headers: { Accept: 'application/ld+json' }
+			});
+			if (response.ok) {
+				const resJson = await response.json();
+				const libraryMainEntity = resJson['mainEntity'] as FramedData;
+				if (libraryMainEntity) {
+					cachedHolders[h.sigel] = libraryMainEntity;
+				}
+			} else {
+				console.error(`Could not fetch holder data for ${id}`);
+			}
+		}
+	}
+}
path: lxl-web/src/lib/utils/holdings.ts
user: login: olovy
body: could be the same fetch?
error handling missing in the first version.
created_at: 2025-05-23T11:45:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,3 +203,163 @@ export function getMyLibsFromHoldings(
 	}
 	return Object.values(result);
 }
+
+export async function getFullHolderData(allHolders: DecoratedHolder[]): Promise<FullHolderBySigel> {
+	const holderBySigel: FullHolderBySigel = {};
+
+	for (const h of allHolders) {
+		const id = h.obj?.['@id'];
+		const libraryRes = await fetch(`${id}?framed=true`, {
+			headers: { Accept: 'application/ld+json' }
+		});
+		const resJson = await libraryRes.json();
+		const libraryMainEntity = resJson['mainEntity'] as FramedData;
+
+		if (libraryMainEntity) {
+			holderBySigel[h.sigel] = libraryMainEntity;
+		}
+	}
+	return holderBySigel;
+}
+
+export async function fetchHoldersIfAbsent(holdersByType: HoldersByType) {
+	const cachedHolders = holdersCache.holders;
+	const allHolders = Object.values(holdersByType).flat();
+	for (const h of allHolders) {
+		const id = h.obj?.['@id'];
+
+		if (h.sigel && cachedHolders && !cachedHolders[h.sigel]) {
+			const response = await fetch(`${id}?framed=true`, {
+				headers: { Accept: 'application/ld+json' }
+			});
+			if (response.ok) {
+				const resJson = await response.json();
+				const libraryMainEntity = resJson['mainEntity'] as FramedData;
+				if (libraryMainEntity) {
+					cachedHolders[h.sigel] = libraryMainEntity;
+				}
+			} else {
+				console.error(`Could not fetch holder data for ${id}`);
+			}
+		}
+	}
+}
+
+export function getItemLinksByBibId(
+	bibIdsByInstanceId: Record<string, BibIdObj>,
+	locale: LocaleCode,
+	displayUtil: DisplayUtil
+): ItemLinksByBibId {
+	const linksByInstanceId: ItemLinksByBibId = {};
+	for (const bibIdObj of Object.values(bibIdsByInstanceId || [])) {
+		const linksForHolder: ItemLinksForHolder = {};
+		bibIdObj.holders?.forEach((sigel) => {
+			if (holdersCache.holders) {
+				const fullHolderData = holdersCache.holders[sigel];
+
+				const ilsPaths = [
+					[BibDb.ils, BibDb.bibIdSearchUri],
+					[BibDb.ils, BibDb.isbnSearchUri],
+					[BibDb.ils, BibDb.issnSearchUri]
+				];
+
+				const lopacPaths = [[BibDb.lopac, BibDb.bibIdSearchUriByLang]];
+
+				let linksToItem = getLinksToItemFor(bibIdObj, fullHolderData, ilsPaths, locale);
+				const lopacLinksItem = getLinksToItemFor(bibIdObj, fullHolderData, lopacPaths, locale);
+
+				const linkTemplateEod = getAtPath(fullHolderData, [BibDb.eodUri], []);
+				if (linkTemplateEod && linkTemplateEod.length !== 0) {
+					linksToItem = [linkTemplateEod.replace(/%BIB_*ID%/, bibIdObj.bibId), ...linksToItem];
path: lxl-web/src/lib/utils/holdings.ts
user: login: olovy
body: this will only replace the first occurence of %BIB_*ID%
same comment for all `replace`

created_at: 2025-05-23T11:48:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,356 @@
+<script lang="ts">
+	import { page } from '$app/state';
+	import { type HoldingStatus } from '$lib/types/api';
+	import type { BibIdObj, DecoratedHolder, ItemLinksByBibId } from '$lib/types/holdings';
+	import { ShowLabelsOptions } from '$lib/types/decoratedData';
+	import DecoratedData from '$lib/components/DecoratedData.svelte';
+	import BiChevronRight from '~icons/bi/chevron-right';
+	import { BibDb } from '$lib/types/xl';
+
+	type HoldingInfoProps = {
+		holder: DecoratedHolder;
+		holdingUrl: string;
+		linksByBibId: ItemLinksByBibId;
+	};
+
+	const { holder, holdingUrl, linksByBibId }: HoldingInfoProps = $props();
+
+	const sigel = holder?.sigel;
+	let loading = $state(false);
+	let statusData: HoldingStatus[] | undefined = $state();
+	let error: string | undefined = $state();
+
+	// if holdingUrl is an instance fnurgel, add its mapped bibId object into arr,
+	// else add all ids of current type with holdings for current sigel
+	const bibIds = $derived.by(() => {
+		return page.data.bibIdsByInstanceId?.[holdingUrl]
+			? [page.data.bibIdsByInstanceId[holdingUrl]]
+			: Object.keys(page.data.bibIdsByInstanceId)
+					.filter((i) => page.data.bibIdsByInstanceId[i]['@type'] === holdingUrl)
+					.filter((i) => page.data.bibIdsByInstanceId[i].holders?.includes(sigel))
+					.map((i) => page.data.bibIdsByInstanceId[i]);
+	});
+
+	async function fetchHoldingStatus(ids: BibIdObj[]) {
+		const promises = ids.map((id) => {
+			if (id) {
+				const searchParams = new URLSearchParams();
+				searchParams.set('bib_id', id.bibId);
+				searchParams.set('sigel', sigel);
+				if (id.onr) {
+					searchParams.set('onr', id.onr);
+				}
+				id.isbn.forEach((i: string) => {
+					searchParams.set('isbn', i);
+				});
+				id.issn.forEach((i: string) => {
+					searchParams.set('issn', i);
+				});
+				return fetch(`/api/holdingstatus?${searchParams.toString()}`);
+			}
+		});
+
+		const responses = await Promise.all(promises);
+		const data = await Promise.all(
+			responses.map((response) => {
+				if (response && response.ok) {
+					return response.json();
+				} else {
+					error = `${page.data.t('holdings.loanStatusFailed')}: ${response?.status} ${response?.statusText}`;
+				}
+			})
+		);
+		return data;
+	}
+
+	async function getHoldingStatus() {
+		if (!sigel || !bibIds || bibIds.length < 1) {
+			error = page.data.t('holdings.loanStatusFailed');
+		} else if (!statusData) {
+			loading = true;
+
+			fetchHoldingStatus(bibIds)
+				.then((data) => {
+					statusData = data;
+					loading = false;
+				})
+				.catch((err) => {
+					error = `${page.data.t('holdings.loanStatusFailed')}: ${err}`;
+					loading = false;
+				});
+		}
+	}
+
+	function getIndicator(status: string) {
+		if (status && typeof status === 'string') {
+			switch (status.toLowerCase()) {
+				case 'tillgänglig':
+				case 'ej utlånad':
+				case 'available':
+					return 'available';
+				case 'ej tillgänglig':
+				case 'utlånad':
+				case 'on loan':
+				case 'not available':
+					return 'unavailable';
+				default:
+					return false;
+			}
+		}
+		return false;
+	}
+
+	function urlNotDefinedError(err: string | undefined) {
+		if (
+			err &&
+			err === `URL till lånestatus för ${sigel} ej definierad i LIBRIS biblioteksdatabas!`
+		) {
+			return true;
+		}
+		return false;
+	}
+
+	function hasLinksToItem(id: string) {
+		return linksByBibId[id]?.[holder.sigel]?.[BibDb.LinksToItem];
+	}
+
+	function hasLoanReserveLink(id: string) {
+		return linksByBibId[id]?.[holder.sigel]?.[BibDb.LoanReserveLink];
+	}
+
+	function missingAtLeastOneLinkToItem() {
+		return bibIds.find((id) => !hasLinksToItem(id.bibId) && !hasLoanReserveLink(id.bibId));
+	}
+</script>
+
+<li class="border-neutral text-xs not-last:border-b">
+	<div class="my-3">
+		<span class="holder-label">
+			<DecoratedData data={holder.obj} showLabels={ShowLabelsOptions['Never']} />
+		</span>
+		<ul>
+			{#each bibIds as id (id.bibId)}
+				{#if linksByBibId[id.bibId]?.[holder.sigel]}
+					<div class="my-1">
+						{#if hasLoanReserveLink(id.bibId)}
+							<li>
+								<div class="instance-token mt-2 mb-1">{id.str}</div>
+								<a
+									href={linksByBibId[id.bibId][holder.sigel]['loanReserveLink'].at(0)}
+									target="_blank"
+									class="btn btn-outlined ext-link h-9"
+								>
+									{page.data.t('holdings.loanReserveLink')}
+								</a>
+							</li>
+						{:else if hasLinksToItem(id.bibId)}
+							<li>
+								<div class="instance-token mt-2 mb-1">{id.str}</div>
+								<a
+									href={linksByBibId[id.bibId][holder.sigel][BibDb.LinksToItem].at(0)}
+									target="_blank"
+									class="btn btn-outlined ext-link h-9"
+								>
+									{page.data.t('holdings.linkToLocal')}
+								</a>
+							</li>
+						{/if}
+					</div>
+				{/if}
+			{/each}
+			{#if bibIds.at(0) && missingAtLeastOneLinkToItem()}
+				{@const firstBibId = bibIds.at(0).bibId}
+				<div class="mt-2">
+					{#if linksByBibId[firstBibId]?.[holder.sigel]?.[BibDb.LinksToCatalog]}
+						<li>
+							<a
+								href={linksByBibId[firstBibId][holder.sigel][BibDb.LinksToCatalog].at(0)}
+								target="_blank"
+								class="ext-link"
+							>
+								{page.data.t('holdings.linkToCatalog')}
+							</a>
+						</li>
+					{:else if linksByBibId[firstBibId]?.[holder.sigel]?.[BibDb.LinksToSite]}
+						<li>
+							<a
+								href={linksByBibId[firstBibId][holder.sigel][BibDb.LinksToSite].at(0)}
+								target="_blank"
+								class="ext-link"
+							>
+								{page.data.t('holdings.linkToSite')}
+							</a>
+						</li>
+					{/if}
+				</div>
+			{/if}
+		</ul>
+		{#if bibIds.at(0) && linksByBibId[bibIds.at(0).bibId]?.[holder.sigel]?.[BibDb.ItemStatus]}
+			<details ontoggle={getHoldingStatus}>
+				<summary class="mt-3 flex cursor-pointer items-baseline">
+					<span class="arrow text-subtle mr-2 h-3 origin-center rotate-0 transition-transform">
+						<BiChevronRight />
+					</span>
+					Visa tillgänglighet
+				</summary>
+				<div class="mb-4 flex flex-col gap-2">
+					{#if loading}
+						<p>{page.data.t('search.loading')}</p>
+					{/if}
+					{#if error}
+						<div
+							class="status-container border-neutral bg-page mt-2 max-w-md rounded-sm border p-2"
+						>
+							<p class="error" role="alert">{error}</p>
+						</div>
+					{/if}
+					{#if statusData && statusData.length > 0}
+						{#each statusData as instance, index (index)}
+							{#if instance?.item_information}
+								{@const items = instance.item_information}
+								<div
+									class="status-container border-neutral bg-page mt-4 flex max-w-md flex-col gap-4 rounded-sm border p-2"
+								>
+									{#if items.error || items.count === 0}
+										{#if urlNotDefinedError(items.error)}
+											<p class="library-unavailable">{page.data.t('holdings.libraryUnvaliable')}</p>
+										{:else}
+											<p class="error" role="alert">{page.data.t('holdings.loanStatusFailed')}</p>
+										{/if}
+									{/if}
+									{#each items.items as item, index (index)}
+										{@const indicator = getIndicator(item.Status)}
+										<table>
+											<tbody class="text-xs leading-relaxed">
+												<tr>
+													<th>{page.data.t('holdings.location')}</th>
+													<td>{item.Location}</td>
+												</tr>
+												<tr>
+													<th>{page.data.t('holdings.shelf')}</th>
+													<td>
+														{item.Call_No}
+														<!-- show map link only if absolute url -->
+														{#if item.Map && (item.Map.startsWith('http://') || item.Map.startsWith('https://'))}
+															(<a href={item.Map} target="_blank" class="ext-link">
+																{page.data.t('holdings.map')}</a
+															>)
+														{/if}
+													</td>
+												</tr>
+												<tr>
+													<th>{page.data.t('holdings.loanPolicy')}</th>
+													<td>{item.Loan_Policy}</td>
+												</tr>
+												<tr>
+													<th>{page.data.t('holdings.status')}</th>
+													<td>
+														{#if indicator}
+															<span class={`indicator ${indicator}`}></span>
+															{page.data.t(`holdings.${indicator}`)}
+														{:else}
+															{item.Status}
+														{/if}
+													</td>
+												</tr>
+												{#if item.Status_Date}
+													<tr>
+														<th>{page.data.t('holdings.date')}</th>
+														<td>{item?.Status_Date_Description} {item.Status_Date}</td>
+													</tr>
+												{/if}
+											</tbody>
+										</table>
+									{/each}
+								</div>
+							{/if}
+						{/each}
+					{/if}
+				</div>
+			</details>
+		{/if}
+	</div>
+	<details>
+		<summary class="my-3 flex cursor-pointer items-baseline">
+			<span class="arrow text-subtle mr-2 h-3 origin-center rotate-0 transition-transform">
+				<BiChevronRight />
+			</span>
+			{page.data.t('holdings.openingHoursEtc')}
+		</summary>
+		<span>
+			{#if bibIds.at(0)}
+				{@const firstBibId = bibIds.at(0).bibId}
+				<ul class="my-2" style="white-space: pre-line">
+					{#if linksByBibId[firstBibId]?.[holder.sigel]?.[BibDb.OpeningHours]}
+						<li>
+							{linksByBibId[firstBibId][holder.sigel][BibDb.OpeningHours].at(0)}
+						</li>
+					{/if}
+					{#if linksByBibId[firstBibId]?.[holder.sigel]?.[BibDb.Address]}
+						{#each linksByBibId[firstBibId][holder.sigel][BibDb.Address] as address (address)}
path: lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/HoldingInfo.svelte
user: login: jesperengstrom
body: can't use `adress` as key here because it's sometimes missing...
created_at: 2025-05-23T12:02:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,3 +203,163 @@ export function getMyLibsFromHoldings(
 	}
 	return Object.values(result);
 }
+
+export async function getFullHolderData(allHolders: DecoratedHolder[]): Promise<FullHolderBySigel> {
+	const holderBySigel: FullHolderBySigel = {};
+
+	for (const h of allHolders) {
+		const id = h.obj?.['@id'];
+		const libraryRes = await fetch(`${id}?framed=true`, {
+			headers: { Accept: 'application/ld+json' }
+		});
+		const resJson = await libraryRes.json();
+		const libraryMainEntity = resJson['mainEntity'] as FramedData;
+
+		if (libraryMainEntity) {
+			holderBySigel[h.sigel] = libraryMainEntity;
+		}
+	}
+	return holderBySigel;
+}
+
+export async function fetchHoldersIfAbsent(holdersByType: HoldersByType) {
+	const cachedHolders = holdersCache.holders;
+	const allHolders = Object.values(holdersByType).flat();
+	for (const h of allHolders) {
+		const id = h.obj?.['@id'];
+
+		if (h.sigel && cachedHolders && !cachedHolders[h.sigel]) {
+			const response = await fetch(`${id}?framed=true`, {
+				headers: { Accept: 'application/ld+json' }
+			});
+			if (response.ok) {
+				const resJson = await response.json();
+				const libraryMainEntity = resJson['mainEntity'] as FramedData;
+				if (libraryMainEntity) {
+					cachedHolders[h.sigel] = libraryMainEntity;
+				}
+			} else {
+				console.error(`Could not fetch holder data for ${id}`);
+			}
+		}
+	}
+}
path: lxl-web/src/lib/utils/holdings.ts
user: login: lrosenstrom
body: The first function is unused and was probably used before adding the cache+error handling. Must have missed to delete it! Good catch
created_at: 2025-05-23T12:08:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: jesperengstrom
created_at: 2025-05-23T12:26:10Z
body: Nice work!

Apart from [comment](https://github.com/libris/lxlviewer/pull/1291/files#pullrequestreview-2864182329) that breaks the app for me, I mainly have comments regarding the look-and-feel which can be iterated. For example...

<img width="1014" alt="Skärmavbild 2025-05-23 kl  14 12 32" src="https://github.com/user-attachments/assets/31c46bf6-66ad-47ed-a2d5-fa2d696f2106" />

* I think this blue button is too wide :)
* I think we need some visual hierarchy with so much information. For example: indenting the opening hours a bit so it's visually a child of the summary label.
* Shouldn't we check if there are opening hours earlier and skip this section if it's missing? (lots of empty 'opening hours')

author_association: CONTRIBUTOR
user: login: lrosenstrom
created_at: 2025-05-26T12:49:00Z
body: Put item availability together with the rest of the item/instance info and made some improvements to the layout

![Screenshot from 2025-05-26 14-40-15](https://github.com/user-attachments/assets/9858b31f-ae79-4d12-a46e-861d5e5e1d6e)

author_association: CONTRIBUTOR