meetings.forEach(m => {
const purpose = (m.purpose || '').toLowerCase();
const hasAmount = m.extracted_dividend_amount != null && m.extracted_dividend_amount !== '';
- const isAGM = purpose.includes('agm') || purpose.includes('annual general meeting');
+ // Ensure robust case-insensitive AGM matching
+ const isAGM = purpose.includes('agm') || purpose.includes('annual general meeting') || /\bagm\b/i.test(m.purpose || '');
if (purpose.includes('dividend') || purpose.includes('bonus') || purpose.includes('split') || purpose.includes('sub-division') || hasAmount || isAGM) {
// Check if there is a corporate action after this meeting date
@@ -3308,6 +3310,9 @@
API Key Management (Secure Session)
if (amountMatch && !amount) {
amount = amountMatch[1];
}
+ } else if (isAGM && !purpose.includes('dividend') && !hasAmount) {
+ // Preserve the explicit AGM context in the Type column if it's purely an AGM intimation
+ divType = 'AGM';
} else if (purpose.includes('bonus')) {
divType = 'Bonus';
} else if (purpose.includes('split') || purpose.includes('sub-division')) {
@@ -3359,11 +3364,21 @@
API Key Management (Secure Session)
for (let existing of deduplicatedSynthetics) {
const existingMeetingDate = existing._matchedMeeting && existing._matchedMeeting.meeting_date ? new Date(existing._matchedMeeting.meeting_date).getTime() : NaN;
- // If meetings are within 5 days and have the same purpose/type, treat as duplicate
+
if (!isNaN(synMeetingDate) && !isNaN(existingMeetingDate)) {
- const diffDays = Math.abs(synMeetingDate - existingMeetingDate) / (1000 * 60 * 60 * 24);
- if (diffDays <= 5 && syn.dividend_type === existing.dividend_type) {
+ const d1 = new Date(synMeetingDate);
+ const d2 = new Date(existingMeetingDate);
+
+ // Merge synthetics if they are within 60 days of each other and have the same dividend type
+ // This handles rescheduled meetings like BPCL (12th to 19th) natively by absorbing the older one
+ const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24);
+
+ if (diffDays <= 60 && syn.dividend_type === existing.dividend_type) {
isDuplicate = true;
+ // Keep the latest broadcast/meeting date info in the existing
+ if (syn._matchedMeeting && (!existing._matchedMeeting || new Date(syn._matchedMeeting.meeting_date) > new Date(existing._matchedMeeting.meeting_date))) {
+ existing._matchedMeeting = syn._matchedMeeting;
+ }
// Update amount if the newer duplicate has it
if ((!existing.parsed_dividend_amount || existing.parsed_dividend_amount === '-') && syn.parsed_dividend_amount) {
existing.parsed_dividend_amount = syn.parsed_dividend_amount;
@@ -3384,7 +3399,7 @@
API Key Management (Secure Session)
const synDate = new Date(syn.broadcast_date || syn.date).getTime();
// Sort officials by closest date first to prevent linking to a distant corporate action
- let availableOfficials = group.officials.filter(o => !processedOfficials.has(o));
+ let availableOfficials = [...group.officials]; // Do not filter out processed ones! Multiple intimations can map to the same CA
availableOfficials.sort((a, b) => {
const aDate = new Date(a.ex_date || a.record_date || a.broadcast_date || a.date).getTime();
const bDate = new Date(b.ex_date || b.record_date || b.broadcast_date || b.date).getTime();
@@ -3397,18 +3412,19 @@
API Key Management (Secure Session)
if (!isNaN(synDate) && !isNaN(offDate)) {
const diffDays = (offDate - synDate) / (1000 * 60 * 60 * 24);
- // If the corporate action happens -10 to 90 days after the board meeting, it's the same cycle!
- // We strictly check that the dividend types match.
- if (diffDays >= -10 && diffDays <= 90 && (syn.dividend_type === off.dividend_type || syn.dividend_type === '-')) {
+ // If the corporate action happens -10 to 180 days after the board meeting, it's the same cycle!
+ // We check that the dividend types loosely match.
+ if (diffDays >= -10 && diffDays <= 180 && (syn.dividend_type === off.dividend_type || syn.dividend_type === '-' || off.dividend_type === '-')) {
// Link them!
- // Do not overwrite the official broadcast_date with the intimation's date,
- // as we want to preserve the actual corporate action announcement time.
if (!off.parsed_dividend_amount || off.parsed_dividend_amount === "-") {
off.parsed_dividend_amount = syn.parsed_dividend_amount;
}
- if (syn._matchedMeeting) off._matchedMeeting = syn._matchedMeeting;
- processedOfficials.add(off);
+ // If this official already matched another meeting, keep the most relevant one (newest meeting date)
+ if (!off._matchedMeeting || (syn._matchedMeeting && new Date(syn._matchedMeeting.meeting_date) > new Date(off._matchedMeeting.meeting_date))) {
+ off._matchedMeeting = syn._matchedMeeting;
+ }
+
matched = true;
break;
}
@@ -3467,6 +3483,7 @@
API Key Management (Secure Session)
if (selectedTypes.includes('Special') && purpose.includes('special')) typeMatchedAny = true;
if (selectedTypes.includes('Bonus') && purpose.includes('bonus')) typeMatchedAny = true;
if (selectedTypes.includes('Split') && (purpose.includes('split') || purpose.includes('sub-division'))) typeMatchedAny = true;
+ if (selectedTypes.includes('AGM') && (purpose.includes('agm') || purpose.includes('annual general meeting') || /\bagm\b/i.test(d.purpose || ''))) typeMatchedAny = true;
if (!typeMatchedAny) matchType = false;
}
@@ -3534,21 +3551,24 @@
API Key Management (Secure Session)
}
const formatDateTime = (dateStr) => {
- if (!dateStr || dateStr === '-') return '-';
+ if (!dateStr || dateStr === '-') return { date: '-', time: null };
try {
let dt = new Date(dateStr);
- if (isNaN(dt.getTime())) return dateStr; // fallback if invalid
+ if (isNaN(dt.getTime())) return { date: dateStr, time: null };
+
+ // Keep local timezone exactly as is instead of shifting to UTC
let y = dt.getFullYear(), m = ('0' + (dt.getMonth() + 1)).slice(-2), dy = ('0' + dt.getDate()).slice(-2);
let datePart = `${y}-${m}-${dy}`;
- // Try to extract time from original string
- let timePart = dateStr.includes('T') ? dateStr.split('T')[1].split('+')[0].split('.')[0] : (dateStr.includes(' ') ? dateStr.split(' ')[1] : null);
- if (timePart && timePart !== '00:00:00') {
- return `${datePart} ${timePart}`;
+ // Try to extract exact time from original string to avoid UTC shifting issues
+ let timePart = null;
+ if (typeof dateStr === 'string') {
+ timePart = dateStr.includes('T') ? dateStr.split('T')[1].split('+')[0].split('.')[0] : (dateStr.includes(' ') ? dateStr.split(' ')[1] : null);
}
- return datePart;
+
+ return { date: datePart, time: (timePart && timePart !== '00:00:00') ? timePart : null };
} catch(e) {
- return dateStr;
+ return { date: dateStr, time: null };
}
};
@@ -3558,17 +3578,17 @@
API Key Management (Secure Session)
let series = d.symbol || '-';
let matchingMeetings = meetingsBySymbol[series.toUpperCase()] || [];
- let bmd = '-';
+ let bmdObj = { date: '-', time: null };
let bmPurpose = '-';
// Board Meeting and Broadcast Date matching logic
- let bcd = '-';
+ let bcdObj = { date: '-', time: null };
if (d.is_synthetic && d._matchedMeeting) {
let matchedMeeting = d._matchedMeeting;
- bmd = formatDateTime(matchedMeeting.meeting_date);
+ bmdObj = formatDateTime(matchedMeeting.meeting_date);
bmPurpose = matchedMeeting.purpose || '-';
- bcd = formatDateTime(matchedMeeting.broadcast_date || matchedMeeting.date);
+ bcdObj = formatDateTime(matchedMeeting.broadcast_date || matchedMeeting.date);
} else if (matchingMeetings.length > 0) {
let exDateObj = d.ex_date ? new Date(d.ex_date) : null;
@@ -3603,7 +3623,7 @@
// Always prioritize the official action's broadcast date if it has one,
// otherwise fallback to the intimation's broadcast date.
let officialBcd = formatDateTime(d.broadcast_date);
- if (officialBcd !== '-') {
- bcd = officialBcd;
- } else if (bcd === '-') {
- bcd = formatDateTime(d.date);
+ if (officialBcd.date !== '-') {
+ bcdObj = officialBcd;
+ } else if (bcdObj.date === '-') {
+ bcdObj = formatDateTime(d.date);
}
let fullPurpose = d.subject || d.purpose || '-';
@@ -3648,13 +3668,17 @@
API Key Management (Secure Session)
else if (lowerType.includes('special')) bg = '#9370DB'; // Subtle Purple
else if (lowerType.includes('bonus')) bg = '#E91E63'; // Pink
else if (lowerType.includes('split') || lowerType.includes('sub-division')) bg = '#2196F3'; // Blue
+ else if (lowerType.includes('agm')) bg = '#607D8B'; // Grey Blue for AGM
typeBadge = `${divType}`;
}
+ let bcdDisplay = bcdObj.date !== '-' ? `${bcdObj.date} ${bcdObj.time ? ` ${bcdObj.time}` : ''}` : '-';
+ let bmdDisplay = bmdObj.date !== '-' ? `${bmdObj.date} ${bmdObj.time ? ` ${bmdObj.time}` : ''}` : '-';
+
tr.innerHTML = `
-
${bcd}
-
${bmd}
+
${bcdDisplay}
+
${bmdDisplay}
${bmPurpose}
${d.ex_date || '-'}
${series}
diff --git a/backend/web/api/data/special_sit_routes.py b/backend/web/api/data/special_sit_routes.py
index 675485a0..c9c70650 100644
--- a/backend/web/api/data/special_sit_routes.py
+++ b/backend/web/api/data/special_sit_routes.py
@@ -208,16 +208,33 @@ def get_special_sit_dividends(db: Session = Depends(get_db)):
# Append remaining BMs that haven't dropped an official CA yet (Upcoming Dividends/Intimations)
for bm in bms:
amt = bm.extracted_dividend_amount
- chained_history.append({
- "ex_date": 'Record date not yet declared',
- "ex_date_obj": None,
- "announcement_date_obj": bm.date,
- "broadcast_date": bm.broadcast_date,
- "dividend_type": bm.extracted_dividend_type or 'Interim',
- "purpose": bm.purpose or "Dividend Declared in Board Meeting",
- "amount": amt,
- "raw_amount": amt
- })
+ purpose_lower = (bm.purpose or '').lower()
+
+ # To avoid polluting the Special Situations UI with generic "Financial Results" or "AGM" meetings
+ # that have no actual declared dividend amount, strictly enforce that an amount must exist.
+ # However, we MUST preserve upcoming intimations (meetings that haven't happened yet),
+ # because most companies announce upcoming dividends with the purpose "Financial Results & Dividend".
+ is_valid_standalone = False
+ if amt is not None:
+ is_valid_standalone = True
+ elif bm.date and bm.date >= today:
+ # It's an upcoming meeting in the future, we don't have the amount yet. Allow it to show as 'Expected'.
+ is_valid_standalone = True
+ elif 'dividend' in purpose_lower and not any(x in purpose_lower for x in ['financial results', 'agm', 'annual general meeting', 'postponed']):
+ # It's a pure historical dividend intimation without a CA
+ is_valid_standalone = True
+
+ if is_valid_standalone:
+ chained_history.append({
+ "ex_date": 'Record date not yet declared',
+ "ex_date_obj": None,
+ "announcement_date_obj": bm.date,
+ "broadcast_date": bm.broadcast_date,
+ "dividend_type": bm.extracted_dividend_type or 'Interim',
+ "purpose": bm.purpose or "Dividend Declared in Board Meeting",
+ "amount": amt,
+ "raw_amount": amt
+ })
chained_history.sort(key=lambda x: x['ex_date_obj'] if x['ex_date_obj'] else (x.get('announcement_date_obj') or datetime.date.min), reverse=True)
ca_by_symbol[sym] = chained_history
diff --git a/backend/web/api/data/view_routes.py b/backend/web/api/data/view_routes.py
index 382c5203..008a9e7d 100644
--- a/backend/web/api/data/view_routes.py
+++ b/backend/web/api/data/view_routes.py
@@ -748,7 +748,6 @@ async def list_data(
model.parsed_dividend_amount != None,
model.dividend_type.in_(['Bonus', 'Split'])
))
-
# Handle FO Instrument filter
if type == 'bhavcopy_fo' and instrument and instrument.upper() != 'ALL':
inst_upper = instrument.upper()