في هذه الصفحة
محفظة, متتبع محفظة شخصية خفيفة
هذا المنشور يشرح تصميم وتنفيذ متتبع محفظة browser-first صغير بنيته. التركيز على المتطلبات، التحديات التي واجهتها، وكيف يعالجها الحل النهائي، مع مقاطع كود توضح معالجة الحالة، التخزين المؤقت، والتفاعل مع الرسوم البيانية.
الأهداف والمتطلبات
جمعت هذه الأهداف قبل البرمجة:
- الخصوصية أولاً: الاحتفاظ ببيانات المستخدم محلياً بشكل افتراضي؛ لا تخزين من جانب الخادم ما لم يوافق المستخدم.
- استخدام منخفض للشبكة: تجنب الطلبات المتكررة؛ كون صديقاً للاتصالات المحدودة أو البطيئة.
- واجهة مستخدم متجاوبة: يجب أن تبدو اختيارات النطاق والتفاعل فورية، حتى عندما تكون مكالمات الشبكة ضرورية في الخلفية.
- المتانة: قبول JSON المستوردة أو المعدلة يدوياً وتجنّب إعادة جلب البيانات غير الضرورية.
- قابل للاختبار: يجب أن تحتوي السلوكيات الرئيسية على اختبارات وحدوية لمنع الانحدارات.
تحديات التنفيذ
ظهرت عدة مشاكل عملية أثناء بناء التطبيق:
-
التخزين المؤقت للنطاق مقابل الطزاجة
-
المشكلة: يمنع التخزين المؤقت الطلبات المتكررة، لكن المستخدمين يتوقعون أن تكون البيانات قصيرة المدى طازجة (مثل عرض 1 شهر حيث يهم سعر اليوم).
-
المقايضة المستكشفة:
- جلب 1M دائماً (طازج) لكن تخزين النطاقات الأطول مؤقتاً, أبسط من حيث الصحة، لكن مضطرب للمستخدمين الذين يستوردون السجلات.
- عدم الجلب أبداً عند تغيير النطاق (واجهة المستخدم فقط), فعال لكنه يعرض بيانات قديمة.
-
الحل المختار: تخزين جميع النطاقات مؤقتاً بشكل صريح، لكن تخزين
1Mكنطاق تم جلبه بشكل مستمر بحيث أن البيانات المستوردة التي تغطي بالفعل 1M لن تسبب جلباً؛ بالإضافة إلى ذلك الاحتفاظ بفحص مبني على السجل (أقدم شمعة<=cutoff) لمعاملة السجل المستورد كنطاق مغطى.
-
-
مصفوفات السجل المعدلة يدوياً أو غير المرتبة
-
المشكلة: قد يحتوي JSON المستورد على مصفوفات سجل غير مرتبة أو تبدأ بعد cutoff؛ الفحوصات البسيطة التي تنظر إلى
history[0]قد تكون خاطئة. -
الحل: حساب أقدم طابع زمني عبر مصفوفة السجل (الحد الأدنى) ومقارنة التواريخ UTC الم.NORMALIZED لتحديد التغطية.
-
-
استمرارية حالة لكل عنصر والترحيل
-
المشكلة: تغيير ما يتم استمراريته (مثل التبديل من 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;
}
المساعدون الرئيسيون:
- مساعد
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];
};
- استمرارية
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 هي:

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