Zum Inhalt springen
Auf dieser Seite

Portfolio, ein leichter persönlicher Portfolio-Tracker

Dieser Beitrag beschreibt Design und Implementierung eines browser-first Portfolio-Trackers. Im Fokus: Anforderungen, die Herausforderungen während der Entwicklung und wie die finale Lösung diese adressiert, mit Code-Snippets zu Zustandsverwaltung, Caching und Diagramminteraktion.

Ziele und Anforderungen

Vor dem Programmieren habe ich diese Ziele festgehalten:

  • Datenschutz zuerst: Benutzerdaten standardmäßig lokal halten; keine serverseitige Speicherung, es sei denn, der Benutzer stimmt zu.
  • Geringe Netzwerknutzung: Redundante Anfragen vermeiden; freundlich für limitierte oder langsame Verbindungen.
  • Responsive UI: Bereichsauswahlen und Interaktionen sollten sofort reagieren, auch wenn im Hintergrund Netzwerkaufrufe erforderlich sind.
  • Robustheit: Importierte oder manuell bearbeitete JSON akzeptieren und unnötiges Nachladen von Daten vermeiden.
  • Testbar: Kernverhalten muss über Unit-Tests verfügen, um Regressionen zu verhindern.

Nach oben

Implementierungsherausforderungen

Während der Entwicklung traten mehrere praktische Probleme auf:

  1. Bereichs-Caching vs. Aktualität

    • Problem: Caching verhindert redundante Anfragen, aber Benutzer erwarten, dass kurzfristige Daten aktuell sind (z.B. 1-Monats-Ansicht, bei der der heutige Preis wichtig ist).

    • Abwägungen:

      • Immer 1M (aktuell) abrufen, aber längere Bereiche zwischenspeichern, einfacher für Korrektheit, aber unübersichtlich für Benutzer mit importierten Verlauf.
      • Bei Bereichswechsel nie abrufen (nur UI), effizient, aber riskiert die Anzeige veralteter Daten.
    • Gewählte Lösung: Alle Bereiche explizit zwischenspeichern, aber 1M als persistierter abgerufener Bereich speichern, damit importierte Daten, die bereits 1M abdecken, keinen Abruf auslösen; zusätzlich eine verlaufsbasierte Prüfung (früheste Kerze <= Cutoff) verwenden, um importierten Verlauf als abgedeckten Bereich zu behandeln.

  2. Manuell bearbeitete oder unsortierte Verlaufsarrays

    • Problem: Importiertes JSON kann Verlaufsarrays enthalten, die nicht sortiert sind oder später als der Cutoff beginnen; naive Prüfungen, die history[0] betrachten, können falsch sein.

    • Lösung: Den frühesten Zeitstempel über ein Verlaufsarray berechnen (min) und normalisierte UTC-Daten vergleichen, um die Abdeckung zu bestimmen.

  3. Einzelne Artikel-Zustandspersistierung und Migrationen

    • Problem: Änderungen daran, was persistiert wird (z.B. Wechsel von implizitem 1M zu explizitem fetchedRanges) erfordert die Behandlung vorhandener localStorage-Zustände.

    • Lösung: Persistierte Metadaten explizit machen (beim 1M in fetchedRanges schreiben, wenn appropriate) und optional einen Migration-/Backfill-Pfad bereitstellen, der fetchedRanges für vorhandene Benutzer basierend auf aktuellem history befüllt.

Nach oben

Aktien-Hinzufügen-Formular

Das Aktien-Hinzufügen-Formular ist ein kompakter Ablauf: Symbol eingeben, clientseitig validieren, aktuellen Kurs zur Bestätigung anzeigen, neues Element in den Store schreiben. Neu hinzugefügte Symbole werden nach dem ersten Zyklus vom Auto-Refresh-Loop automatisch aufgegriffen.

Aktien-Hinzufügen-Dialog

Das Aktien-Hinzufügen-Formular mit Symbolvalidierung

Nach oben

Zustandsorganisation

  • Die App speichert den gesamten Zustand unter einem einzigen Root-Key in localStorage (über einen kleinen Helfer). So bleiben Lese-/Schreibvorgänge einfach und Export/Import vorhersehbar.
  • Der Store (StoreProvider) hält das portfolio-Array im React-Zustand und schreibt es bei jeder Änderung in localStorage. Jedes PortfolioItem hat Felder wie:
type PortfolioItem = {
	id: string;
	symbol: string;
	qty: number;
	avgPrice?: number;
	currentPrice?: number;
	history?: { time: string; value: number }[];
	fetchedRanges?: Range[]; // e.g. ['1D','1W','1M']
	lastUpdated?: string;
}

Wichtige Helfer:

  1. storage-Helfer (einfacher JSON-Wrapper um localStorage)
export const readState = () => {
	try { return JSON.parse(localStorage.getItem(ROOT_KEY) || '{}'); } catch (_) { return {}; }
};

export const set = (key: string, value: unknown) => {
	const s = readState(); s[key] = value; localStorage.setItem(ROOT_KEY, JSON.stringify(s));
};

export const get = (key: string, defaultValue?: any) => {
	const s = readState(); return s[key] === undefined ? defaultValue : s[key];
};
  1. StoreProvider-Persistierung und fetched-range-Schreibvorgänge
// persist portfolio whenever it changes
useEffect(() => { storage.set('portfolio', portfolio); }, [portfolio]);

// on successful fetch for an item:
setPortfolio(current => current.map(p => p.id === item.id ? ({
	...p,
	history: mergeHistories(p.history, history),
	lastUpdated: new Date().toISOString(),
	fetchedRanges: Array.from(new Set([...(p.fetchedRanges || []), fetchRange])),
}) : p));

mergeHistories ist ein kleines Hilfsprogramm, das zwei Arrays von datierten Kerzen zusammenführt, Reihenfolge beibehält und Duplikate vermeidet.

Nach oben

Diagramminteraktion und Abrufrichtlinie

Klickt ein Benutzer auf einen Bereichs-Button (1D, 1W, 1M), soll die UI sofort aktualisieren und entscheiden, ob ein Netzwerk-Refresh nötig ist. Der Entscheidungsalgorithmus in handleRangeChange:

Interaktives Portfolio-Diagramm

Interaktives Diagramm mit ausgewähltem Bereich und Einzelaktien-Aufschlüsselung; diese Ansicht aktualisiert sich bei Bereichswechsel.

  1. Lokalen range-Zustand aktualisieren und setSelectedRange im Store aufrufen, damit die globale Auswahl die UI widerspiegelt.
  2. Wenn refreshPortfolioRange im Store verfügbar ist, prüfen, ob ein PortfolioItem bereits den targetRange in fetchedRanges aufgezeichnet hat.
  3. Für 1M zusätzlich prüfen, ob das früheste Datum der history des Artikels am oder vor dem 1M-Cutoff (UTC-normalisiert) liegt; wenn ja, als abgedeckt behandeln.
  4. Keiner der Artikel deckt den Bereich ab, refreshPortfolioRange(targetRange) aufrufen, der fehlenden Verlauf abruft und fetchedRanges aktualisiert.
const handleRangeChange = async (targetRange: Range) => {
	setRange(targetRange); setSelectedRange?.(targetRange);
	if (!refreshPortfolioRange) return;

	const anyHave = portfolio.some(p => {
		if (Array.isArray(p.fetchedRanges) && p.fetchedRanges.includes(targetRange)) return true;
		if (targetRange === '1M' && Array.isArray(p.history) && p.history.length > 0) {
			// compute earliest timestamp robustly
			const earliestTs = Math.min(...p.history.map(h => Date.parse(h.time + 'T00:00:00')));
			const cutoff = getRangeCutoff('1M');
			return Date.UTC(new Date(earliestTs).getFullYear(), new Date(earliestTs).getMonth(), new Date(earliestTs).getDate())
				<= Date.UTC(cutoff.getFullYear(), cutoff.getMonth(), cutoff.getDate());
		}
		return false;
	});

	if (!anyHave) await refreshPortfolioRange(targetRange);
}

Nach oben

Verläufe zusammenführen

Diese Funktion führt zwei Verlaufsarrays zu einem sortierten Array ohne doppelte Datumseinträge zusammen:

function mergeHistories(oldH?: Candle[], newH?: Candle[]): Candle[] {
	const map = new Map<string, number>();
	(oldH || []).forEach(c => map.set(c.time, c.value));
	(newH || []).forEach(c => map.set(c.time, c.value));
	return Array.from(map.entries()).map(([time, value]) => ({ time, value })).sort((a,b) => a.time.localeCompare(b.time));
}

Die Tabellenansicht zeigt zusammengeführte Verlaufszeilen (tägliche Kerzen) für jeden Artikel. mergeHistories stellt sicher, dass Zeilen eindeutig nach Datum sind und chronologisch sortiert sind; diese Tabelle wird sowohl für Exporte als auch für eine schnelle manuelle Prüfung beim Import von JSON verwendet.

Zusammengeführter Verlauf Beispiel

Zusammengeführte Verlaufstabelle für Export/Import und schnelle Prüfung.

Nach oben

Auto-Refresh und Hintergrundaktualisierungen

Die App bietet eine optionale Auto-Refresh-Funktion, die Kurse und kurzfristigen Verlauf im Hintergrund aktualisiert. Ziele:

  • UI reaktionsfähig halten: Daten im Hintergrund laden, ohne Benutzerinteraktionen zu blockieren.
  • Netzwerknutzung minimieren: Nur Bereiche aktualisieren, die als „aktuell” gelten (1D/1W), oder wenn ein Artikel explizit einen Refresh anfordert.
  • Redundante Anfragen vermeiden: fetchedRanges und die verlaufsbasierte Abdeckungsprüfung respektieren.

Portfolio-Header und Steuerung

App-Header und Steuerung, Bereichs-Buttons, Aktien-Hinzufügen und Statusanzeigen (responsive).

Design-Hinweise:

  • Eine leichtgewichtige Polling-Schleife läuft, wenn die App Fokus hat und der Benutzer Auto-Refresh aktiviert hat. Sie ruft periodisch eine refreshPortfolio()-Funktion auf (konfigurierbares Intervall, z.B. 60s).
  • refreshPortfolio() ruft Kurse für alle Artikel ab und ruft optional kurzfristigen Verlauf (z.B. 1D) nur ab, wenn der Artikel keinen aktuellen Zeitstempel hat oder fetchedRanges diesen Bereich nicht enthält.
  • Die UI zeigt einen dezenten Statusindikator, während Hintergrund-Refreshs laufen; einzelne Komponenten aktualisieren sich, wenn der Store neue Daten schreibt.

Vereinfachter Code-Snippet (Store-Polling + geschützter Refresh):

// in StoreProvider
useEffect(() => {
	if (!autoRefreshEnabled) return;
	let cancelled = false;
	const tick = async () => {
		if (cancelled) return;
		try {
			await refreshPortfolio({ onlyShortRanges: true });
		} catch (e) { /* swallow non-fatal errors */ }
		if (!cancelled) setTimeout(tick, AUTO_REFRESH_INTERVAL_MS);
	};
	tick();
	return () => { cancelled = true; };
}, [autoRefreshEnabled]);

// refreshPortfolio guarded by fetchedRanges
export async function refreshPortfolio(opts?: { onlyShortRanges?: boolean }) {
	const rangesToRefresh = opts?.onlyShortRanges ? ['1D'] : ALL_RANGES;
	for (const p of portfolio) {
		for (const r of rangesToRefresh) {
			if (Array.isArray(p.fetchedRanges) && p.fetchedRanges.includes(r)) continue;
			await refreshStock(p.id, r);
		}
	}
}

Hinweise:

  • Die Polling-Schleife ist bewusst konservativ: Standardmäßig werden nur kurzfristige Daten abgerufen, um die Bandbreite niedrig zu halten.
  • refreshStock und refreshPortfolio hängen den abgerufenen Bereich an fetchedRanges an, sodass nachfolgende Zyklen bereits abgedeckte Bereiche überspringen.
  • Fehler bei Hintergrund-Refreshs werden protokolliert und unterbrechen nicht die UI; ein benutzerseitiger Fehlerfluss wird nur für Vordergrund-Aktionen ausgelöst, die vom Benutzer initiiert wurden.

Nach oben

Testen und Verifizierung

  • Das Repository nutzt Vitest und Testing Library für Unit-Tests. Zu den wichtigsten Tests gehören: importPortfolio-Verhalten, refreshPortfolio-Caching-Logik und aktualisierte fetchedRanges-Persistierung für 1M.

Lokal ausführen

git clone https://github.com/ammarnajjar/portfolio.git
cd portfolio
npm ci
npm run dev

Nach oben