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.
Implementierungsherausforderungen
Während der Entwicklung traten mehrere praktische Probleme auf:
-
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
1Mals 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.
-
-
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.
-
-
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
1MinfetchedRangesschreiben, wenn appropriate) und optional einen Migration-/Backfill-Pfad bereitstellen, derfetchedRangesfür vorhandene Benutzer basierend auf aktuellemhistorybefüllt.
-
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.

Das Aktien-Hinzufügen-Formular mit Symbolvalidierung
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 dasportfolio-Array im React-Zustand und schreibt es bei jeder Änderung inlocalStorage. JedesPortfolioItemhat 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:
storage-Helfer (einfacher JSON-Wrapper umlocalStorage)
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];
};
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.
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 Diagramm mit ausgewähltem Bereich und Einzelaktien-Aufschlüsselung; diese Ansicht aktualisiert sich bei Bereichswechsel.
- Lokalen
range-Zustand aktualisieren undsetSelectedRangeim Store aufrufen, damit die globale Auswahl die UI widerspiegelt. - Wenn
refreshPortfolioRangeim Store verfügbar ist, prüfen, ob einPortfolioItembereits dentargetRangeinfetchedRangesaufgezeichnet hat. - Für
1Mzusätzlich prüfen, ob das früheste Datum derhistorydes Artikels am oder vor dem 1M-Cutoff (UTC-normalisiert) liegt; wenn ja, als abgedeckt behandeln. - Keiner der Artikel deckt den Bereich ab,
refreshPortfolioRange(targetRange)aufrufen, der fehlenden Verlauf abruft undfetchedRangesaktualisiert.
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);
}
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ührte Verlaufstabelle für Export/Import und schnelle Prüfung.
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:
fetchedRangesund die verlaufsbasierte Abdeckungsprüfung respektieren.
![]()
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 oderfetchedRangesdiesen 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.
refreshStockundrefreshPortfoliohängen den abgerufenen Bereich anfetchedRangesan, 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.
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 aktualisiertefetchedRanges-Persistierung für1M.
Lokal ausführen
git clone https://github.com/ammarnajjar/portfolio.git
cd portfolio
npm ci
npm run dev