انتقل إلى المحتوى
في هذه الصفحة

محفظة, متتبع محفظة شخصية خفيفة

هذا المنشور يشرح تصميم وتنفيذ متتبع محفظة browser-first صغير بنيته. التركيز على المتطلبات، التحديات التي واجهتها، وكيف يعالجها الحل النهائي، مع مقاطع كود توضح معالجة الحالة، التخزين المؤقت، والتفاعل مع الرسوم البيانية.

الأهداف والمتطلبات

جمعت هذه الأهداف قبل البرمجة:

  • الخصوصية أولاً: الاحتفاظ ببيانات المستخدم محلياً بشكل افتراضي؛ لا تخزين من جانب الخادم ما لم يوافق المستخدم.
  • استخدام منخفض للشبكة: تجنب الطلبات المتكررة؛ كون صديقاً للاتصالات المحدودة أو البطيئة.
  • واجهة مستخدم متجاوبة: يجب أن تبدو اختيارات النطاق والتفاعل فورية، حتى عندما تكون مكالمات الشبكة ضرورية في الخلفية.
  • المتانة: قبول JSON المستوردة أو المعدلة يدوياً وتجنّب إعادة جلب البيانات غير الضرورية.
  • قابل للاختبار: يجب أن تحتوي السلوكيات الرئيسية على اختبارات وحدوية لمنع الانحدارات.

العودة للأعلى

تحديات التنفيذ

ظهرت عدة مشاكل عملية أثناء بناء التطبيق:

  1. التخزين المؤقت للنطاق مقابل الطزاجة

    • المشكلة: يمنع التخزين المؤقت الطلبات المتكررة، لكن المستخدمين يتوقعون أن تكون البيانات قصيرة المدى طازجة (مثل عرض 1 شهر حيث يهم سعر اليوم).

    • المقايضة المستكشفة:

      • جلب 1M دائماً (طازج) لكن تخزين النطاقات الأطول مؤقتاً, أبسط من حيث الصحة، لكن مضطرب للمستخدمين الذين يستوردون السجلات.
      • عدم الجلب أبداً عند تغيير النطاق (واجهة المستخدم فقط), فعال لكنه يعرض بيانات قديمة.
    • الحل المختار: تخزين جميع النطاقات مؤقتاً بشكل صريح، لكن تخزين 1M كنطاق تم جلبه بشكل مستمر بحيث أن البيانات المستوردة التي تغطي بالفعل 1M لن تسبب جلباً؛ بالإضافة إلى ذلك الاحتفاظ بفحص مبني على السجل (أقدم شمعة <= cutoff) لمعاملة السجل المستورد كنطاق مغطى.

  2. مصفوفات السجل المعدلة يدوياً أو غير المرتبة

    • المشكلة: قد يحتوي JSON المستورد على مصفوفات سجل غير مرتبة أو تبدأ بعد cutoff؛ الفحوصات البسيطة التي تنظر إلى history[0] قد تكون خاطئة.

    • الحل: حساب أقدم طابع زمني عبر مصفوفة السجل (الحد الأدنى) ومقارنة التواريخ UTC الم.NORMALIZED لتحديد التغطية.

  3. استمرارية حالة لكل عنصر والترحيل

    • المشكلة: تغيير ما يتم استمراريته (مثل التبديل من 1M الضمني إلى fetchedRanges الصريح) يتطلب معالجة حالات localStorage الموجودة.

    • الحل: جعل البيانات الوصفية المستمرة صريحة (كتابة 1M في fetchedRanges عند الاقتراح) وتقديم خيار مسار ترحيل/ملء خلفي يملأ fetchedRanges للمستخدمين الحاليين بناءً على history الحالي.

العودة للأعلى

نموذج إضافة سهم

نموذج إضافة سهم هو تدفق مختصر يسمح للمستخدمين بإضافة رمز جديد إلى محفظتهم. يتحقق من الرموز من جانب العميل، ويعرض أسعار الاقتراح الأخيرة للتأكيد، ويكتب العنصر الجديد في المتجر. يتم التقاط الرموز المضافة حديثاً بواسطة حلقة التحديث التلقائي بعد الدورة الأولى.

مربع حوار إضافة سهم

نموذج إضافة سهم مع التحقق من الرمز

العودة للأعلى

كيفية تنظيم الحالة

  • يخزن التطبيق الحالة الكاملة للتطبيق تحت مفتاح واحد في localStorage (عن طريق مساعد صغير) لتبسيط عمليات القراءة/الكتابة والتنبؤ بالاستيراد/التصدير.
  • يحتفظ المتجر (StoreProvider) بمصفوفة portfolio في حالة React ويكتبها في localStorage عند كل تغيير. كل PortfolioItem له حقول مثل:
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;
}

المساعدون الرئيسيون:

  1. مساعد storage (غلاف JSON بسيط حول 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 وكتابة fetched-ranges
// 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 هو أداة صغيرة تدمج مصفوفتين من الشموع المؤرخة، وتحافظ على الترتيب وتجنب التكرارات.

العودة للأعلى

التفاعل مع الرسوم البيانية وسياسة الجلب

عندما ينقر المستخدم على زر نطاق (مثل 1D، 1W، 1M)، يجب أن تتحدث واجهة المستخدم فوراً وتقرر ما إذا كان تحديث الشبكة ضرورياً. خوارزمية القرار في handleRangeChange هي:

رسم بياني تفاعلي للمحفظة

رسم بياني تفاعلي يظهر النطاق المحدد وتفصيلاً لكل سهم؛ يتم تحديث هذا العرض عند تغيير النطاقات.

  1. تحديث حالة range المحلية واستدعاء setSelectedRange في المتجر بحيث تعكس الاختيار العالمي واجهة المستخدم.
  2. إذا كان refreshPortfolioRange متاحاً في المتجر، تحقق مما إذا كان أي PortfolioItem لديه بالفعل targetRange مسجل في fetchedRanges.
  3. لـ 1M تحقق أيضاً مما إذا كان التاريخ الأقدم لـ history للعنصر عند أو قبل cutoff 1M (مع.NORMALIZED UTC)؛ إذا كان كذلك، عامله كمغطى.
  4. إذا لم يغط أي من العناصر النطاق، استدعاء refreshPortfolioRange(targetRange) الذي سيجلب السجل المفقود ويحدّث fetchedRanges.
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);
}

العودة للأعلى

دمج السجلات

الوظيفة أدناه تضمن أن دمج مصفوفتين من السجلات ينتج مصفوفة مرتبة بدون إدخالات تاريخ مكررة:

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));
}

عرض الجدول يظهر صفوف السجل المدمجة (شموع يومية) لكل عنصر. يضمن mergeHistories أن الصفوف فريدة حسب التاريخ ومرتتبة زمنياً؛ يُستخدم هذا الجدول لكل من التصديرات والفحص السريع عندما يستورد المستخدمون JSON.

نموذج سجل مدمج

جدول سجل مدمج يُستخدم للاستيراد/التصدير والفحص السريع.

العودة للأعلى

التحديث التلقائي والتحديثات في الخلفية

يتضمن التطبيق ميزة تحديث تلقائي اختيارية تحديث الأسعار والسجل قصير المدى في الخلفية. كانت أهداف هذه الميزة:

  • الحفاظ على واجهة المستخدم متجاوبة: تحديث البيانات في الخلفية دون حظر تفاعلات المستخدم.
  • تقليل استخدام الشبكة: جلب التحديثات فقط للنطاقات التي تعتبر “طازجة” (النطاقات القصيرة مثل 1D/1W) أو عندما تحتاج العناصر صراحةً إلى تحديث.
  • تجنب الطلبات المتكررة: احترام fetchedRanges والتحقق من التغطية المبني على السجل المشرح سابقاً.

رأس المحفظة والتحكم

رأس التطبيق والتحكم, أزرار النطاق، إضافة سهم ومؤشرات الحالة (متجاوبة).

ملاحظات التصميم:

  • تعمل حلقة polling خفيفة عندما يكون التطبيق في التركيز وفعّل المستخدم التحديث التلقائي. تستدعي دالة refreshPortfolio() دوريًا (فترة قابلة للتعديل، مثل 60 ثانية).
  • تجلب refreshPortfolio() أسعار جميع العناصر وجلب اختياري للسجل قصير المدى (مثل 1D) فقط إذا لم يكن لدى العنصر بالفعل طابع زمني حديث أو إذا لم يتضمن fetchedRanges ذلك النطاق.
  • تظهر واجهة المستخدم مؤشر حالة خفيفة أثناء تحديثات الخلفية؛ تتحدث المكونات الفردية عندما يكتب المتجر بيانات جديدة.

مقطع كود مبسط (polling المتجر + تحديث محمي):

// 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);
		}
	}
}

ملاحظات:

  • حلقة polling متحفظة عمداً: تطلب فقط بيانات قصيرة المدى بشكل افتراضي للحفاظ على انخفاض تحميل النطاق.
  • تضيف refreshStock وrefreshPortfolio النطاق المجلوب إلى fetchedRanges بحيث تتخطى الدورات اللاحقة النطاقات المغطاة بالفعل.
  • يتم تسجيل أخطاء تحديثات الخلفية ولا تقطع واجهة المستخدم؛ يتم تشغيل تدفق أخطاء المستخدم فقط للإجراءات الأمامية التي بدأها المستخدم.

العودة للأعلى

الاختبار والتحقق

  • يستخدم المستودع Vitest + Testing Library للاختبارات الوحدوية. تشمل الاختبارات المهمة التحقق من سلوك importPortfolio ومنطق تخزين refreshPortfolio واستمرارية fetchedRanges المحدّثة لـ 1M.

كيفية التشغيل محلياً

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

العودة للأعلى