Track email opens and clicks on recipient status.
SMTP2GO open/click webhooks now advance Message to opened/clicked, and the campaign engagement chart uses live stats instead of placeholder bars.
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.1 on 2026-08-10 15:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('messaging', '0004_stored_file'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='message',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('queued', 'Queued'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('opened', 'Opened'), ('clicked', 'Clicked'), ('failed', 'Failed'), ('bounced', 'Bounced'), ('suppressed', 'Suppressed')], default='draft', max_length=16),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -74,6 +74,8 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
|
|||||||
QUEUED = "queued", "Queued"
|
QUEUED = "queued", "Queued"
|
||||||
SENT = "sent", "Sent"
|
SENT = "sent", "Sent"
|
||||||
DELIVERED = "delivered", "Delivered"
|
DELIVERED = "delivered", "Delivered"
|
||||||
|
OPENED = "opened", "Opened"
|
||||||
|
CLICKED = "clicked", "Clicked"
|
||||||
FAILED = "failed", "Failed"
|
FAILED = "failed", "Failed"
|
||||||
BOUNCED = "bounced", "Bounced"
|
BOUNCED = "bounced", "Bounced"
|
||||||
SUPPRESSED = "suppressed", "Suppressed"
|
SUPPRESSED = "suppressed", "Suppressed"
|
||||||
|
|||||||
@@ -352,8 +352,27 @@ def send_campaign_completion_notify(campaign: Campaign) -> bool:
|
|||||||
campaign.notify_sent_at = now
|
campaign.notify_sent_at = now
|
||||||
|
|
||||||
counts = campaign.messages.aggregate(
|
counts = campaign.messages.aggregate(
|
||||||
sent=Count("id", filter=Q(status=Message.Status.SENT)),
|
sent=Count(
|
||||||
delivered=Count("id", filter=Q(status=Message.Status.DELIVERED)),
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
status__in=[
|
||||||
|
Message.Status.SENT,
|
||||||
|
Message.Status.DELIVERED,
|
||||||
|
Message.Status.OPENED,
|
||||||
|
Message.Status.CLICKED,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
delivered=Count(
|
||||||
|
"id",
|
||||||
|
filter=Q(
|
||||||
|
status__in=[
|
||||||
|
Message.Status.DELIVERED,
|
||||||
|
Message.Status.OPENED,
|
||||||
|
Message.Status.CLICKED,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
failed=Count(
|
failed=Count(
|
||||||
"id",
|
"id",
|
||||||
filter=Q(
|
filter=Q(
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
<p class="hint-block" style="margin-top:0">
|
<p class="hint-block" style="margin-top:0">
|
||||||
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
|
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
|
||||||
<strong data-stat="clicks">{{ stats.clicks }}</strong> clicked
|
<strong data-stat="clicks">{{ stats.clicks }}</strong> clicked
|
||||||
({{ stats.open_events }} open events / {{ stats.click_events }} click events from SMTP2GO).
|
(<span data-stat="open_events">{{ stats.open_events }}</span> open events /
|
||||||
|
<span data-stat="click_events">{{ stats.click_events }}</span> click events from SMTP2GO).
|
||||||
</p>
|
</p>
|
||||||
{% elif campaign.channel == "sms" %}
|
{% elif campaign.channel == "sms" %}
|
||||||
<p class="hint-block" style="margin-top:0">
|
<p class="hint-block" style="margin-top:0">
|
||||||
@@ -103,12 +104,24 @@
|
|||||||
Postcard status updates from PCM Integrations webhooks.
|
Postcard status updates from PCM Integrations webhooks.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="chart-placeholder" aria-hidden="true">
|
<div class="chart-placeholder" id="engagement-chart" role="img"
|
||||||
<div class="bar" style="height:55%"></div>
|
aria-label="Campaign engagement chart">
|
||||||
<div class="bar" style="height:70%"></div>
|
{% for bar in stats.chart_bars %}
|
||||||
<div class="bar" style="height:40%"></div>
|
<div class="chart-bar-col">
|
||||||
<div class="bar" style="height:30%"></div>
|
<div class="bar" style="height:{{ bar.pct }}%"
|
||||||
<div class="bar" style="height:20%"></div>
|
title="{{ bar.label }}: {{ bar.value }}"
|
||||||
|
data-bar-label="{{ bar.label }}"></div>
|
||||||
|
<div class="chart-bar-meta">
|
||||||
|
<span class="chart-bar-value" data-bar-value="{{ bar.label }}">{{ bar.value }}</span>
|
||||||
|
<span class="chart-bar-label">{{ bar.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="chart-bar-col">
|
||||||
|
<div class="bar" style="height:12%"></div>
|
||||||
|
<div class="chart-bar-meta"><span class="chart-bar-label">—</span></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,10 +230,24 @@
|
|||||||
badge.className = "badge badge-" + data.status;
|
badge.className = "badge badge-" + data.status;
|
||||||
}
|
}
|
||||||
Object.keys(data.stats || {}).forEach(function (key) {
|
Object.keys(data.stats || {}).forEach(function (key) {
|
||||||
|
if (key === "chart_bars") return;
|
||||||
document.querySelectorAll('[data-stat="' + key + '"]').forEach(function (el) {
|
document.querySelectorAll('[data-stat="' + key + '"]').forEach(function (el) {
|
||||||
el.textContent = data.stats[key];
|
el.textContent = data.stats[key];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
var chart = document.getElementById("engagement-chart");
|
||||||
|
if (chart && Array.isArray(data.stats && data.stats.chart_bars)) {
|
||||||
|
chart.innerHTML = data.stats.chart_bars.map(function (bar) {
|
||||||
|
return '<div class="chart-bar-col">' +
|
||||||
|
'<div class="bar" style="height:' + esc(bar.pct) + '%" title="' +
|
||||||
|
esc(bar.label) + ': ' + esc(bar.value) + '" data-bar-label="' +
|
||||||
|
esc(bar.label) + '"></div>' +
|
||||||
|
'<div class="chart-bar-meta">' +
|
||||||
|
'<span class="chart-bar-value">' + esc(bar.value) + '</span>' +
|
||||||
|
'<span class="chart-bar-label">' + esc(bar.label) + '</span>' +
|
||||||
|
'</div></div>';
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
var body = document.getElementById("recipients-body");
|
var body = document.getElementById("recipients-body");
|
||||||
if (body && data.messages) {
|
if (body && data.messages) {
|
||||||
if (!data.messages.length) {
|
if (!data.messages.length) {
|
||||||
|
|||||||
+38
-2
@@ -382,7 +382,7 @@ class Smtp2goEmailWebhookTests(TestCase):
|
|||||||
).exists()
|
).exists()
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_open_records_event_keeps_delivered(self):
|
def test_open_records_event_sets_opened(self):
|
||||||
self.message.status = Message.Status.DELIVERED
|
self.message.status = Message.Status.DELIVERED
|
||||||
self.message.save(update_fields=["status"])
|
self.message.save(update_fields=["status"])
|
||||||
url = reverse("messaging:email_webhook")
|
url = reverse("messaging:email_webhook")
|
||||||
@@ -395,13 +395,46 @@ class Smtp2goEmailWebhookTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.message.refresh_from_db()
|
self.message.refresh_from_db()
|
||||||
self.assertEqual(self.message.status, Message.Status.DELIVERED)
|
self.assertEqual(self.message.status, Message.Status.OPENED)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
ProviderEvent.objects.filter(
|
ProviderEvent.objects.filter(
|
||||||
message=self.message, event_type="open"
|
message=self.message, event_type="open"
|
||||||
).exists()
|
).exists()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_click_sets_clicked_from_sent(self):
|
||||||
|
url = reverse("messaging:email_webhook")
|
||||||
|
self.client.post(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
"event": "click",
|
||||||
|
"rcpt": "pat@example.com",
|
||||||
|
"X-Monica-Message-Id": str(self.message.pk),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.message.refresh_from_db()
|
||||||
|
self.assertEqual(self.message.status, Message.Status.CLICKED)
|
||||||
|
self.assertTrue(
|
||||||
|
ProviderEvent.objects.filter(
|
||||||
|
message=self.message, event_type="click"
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_open_does_not_downgrade_clicked(self):
|
||||||
|
self.message.status = Message.Status.CLICKED
|
||||||
|
self.message.save(update_fields=["status"])
|
||||||
|
url = reverse("messaging:email_webhook")
|
||||||
|
self.client.post(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
"event": "open",
|
||||||
|
"rcpt": "pat@example.com",
|
||||||
|
"X-Monica-Message-Id": str(self.message.pk),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.message.refresh_from_db()
|
||||||
|
self.assertEqual(self.message.status, Message.Status.CLICKED)
|
||||||
|
|
||||||
def test_hard_bounce_suppresses_contact(self):
|
def test_hard_bounce_suppresses_contact(self):
|
||||||
url = reverse("messaging:email_webhook")
|
url = reverse("messaging:email_webhook")
|
||||||
self.client.post(
|
self.client.post(
|
||||||
@@ -449,6 +482,9 @@ class Smtp2goEmailWebhookTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.assertEqual(data["stats"]["opens"], 1)
|
self.assertEqual(data["stats"]["opens"], 1)
|
||||||
|
self.assertIn("chart_bars", data["stats"])
|
||||||
|
labels = [bar["label"] for bar in data["stats"]["chart_bars"]]
|
||||||
|
self.assertEqual(labels, ["Sent", "Delivered", "Opens", "Clicks", "Failed"])
|
||||||
|
|
||||||
def test_bearer_authorization_header(self):
|
def test_bearer_authorization_header(self):
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -536,7 +536,13 @@ def campaign_detail(request, pk):
|
|||||||
Campaign.Status.SENDING,
|
Campaign.Status.SENDING,
|
||||||
}
|
}
|
||||||
and campaign.messages.exclude(
|
and campaign.messages.exclude(
|
||||||
status__in={"sent", "delivered", "suppressed"}
|
status__in={
|
||||||
|
"sent",
|
||||||
|
"delivered",
|
||||||
|
"opened",
|
||||||
|
"clicked",
|
||||||
|
"suppressed",
|
||||||
|
}
|
||||||
).exists(),
|
).exists(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+89
-17
@@ -21,7 +21,7 @@ PROVIDER_SMS = "smtp2go_sms"
|
|||||||
PROVIDER_PCM = "pcm"
|
PROVIDER_PCM = "pcm"
|
||||||
PROVIDER = PROVIDER_EMAIL # backward-compatible alias
|
PROVIDER = PROVIDER_EMAIL # backward-compatible alias
|
||||||
|
|
||||||
# Do not move a message backward to a weaker delivery state.
|
# Do not move a message backward to a weaker delivery / engagement state.
|
||||||
_STATUS_RANK = {
|
_STATUS_RANK = {
|
||||||
Message.Status.DRAFT: 0,
|
Message.Status.DRAFT: 0,
|
||||||
Message.Status.SCHEDULED: 1,
|
Message.Status.SCHEDULED: 1,
|
||||||
@@ -29,10 +29,28 @@ _STATUS_RANK = {
|
|||||||
Message.Status.SENT: 3,
|
Message.Status.SENT: 3,
|
||||||
Message.Status.FAILED: 3,
|
Message.Status.FAILED: 3,
|
||||||
Message.Status.DELIVERED: 4,
|
Message.Status.DELIVERED: 4,
|
||||||
Message.Status.BOUNCED: 5,
|
Message.Status.OPENED: 5,
|
||||||
Message.Status.SUPPRESSED: 5,
|
Message.Status.CLICKED: 6,
|
||||||
|
Message.Status.BOUNCED: 7,
|
||||||
|
Message.Status.SUPPRESSED: 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ENGAGED_OR_DELIVERED = frozenset(
|
||||||
|
{
|
||||||
|
Message.Status.DELIVERED,
|
||||||
|
Message.Status.OPENED,
|
||||||
|
Message.Status.CLICKED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_SENT_OR_BETTER = frozenset(
|
||||||
|
{
|
||||||
|
Message.Status.SENT,
|
||||||
|
Message.Status.DELIVERED,
|
||||||
|
Message.Status.OPENED,
|
||||||
|
Message.Status.CLICKED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
_MONICA_HEADER_KEYS = (
|
_MONICA_HEADER_KEYS = (
|
||||||
"X-Monica-Message-Id",
|
"X-Monica-Message-Id",
|
||||||
"x-monica-message-id",
|
"x-monica-message-id",
|
||||||
@@ -188,6 +206,8 @@ def find_message_for_email_event(payload: dict[str, Any]) -> Message | None:
|
|||||||
Message.Status.QUEUED,
|
Message.Status.QUEUED,
|
||||||
Message.Status.SENT,
|
Message.Status.SENT,
|
||||||
Message.Status.DELIVERED,
|
Message.Status.DELIVERED,
|
||||||
|
Message.Status.OPENED,
|
||||||
|
Message.Status.CLICKED,
|
||||||
Message.Status.FAILED,
|
Message.Status.FAILED,
|
||||||
Message.Status.BOUNCED,
|
Message.Status.BOUNCED,
|
||||||
],
|
],
|
||||||
@@ -207,11 +227,12 @@ def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "")
|
|||||||
Message.Status.FAILED,
|
Message.Status.FAILED,
|
||||||
}:
|
}:
|
||||||
return
|
return
|
||||||
if (
|
if message.status in {Message.Status.BOUNCED, Message.Status.SUPPRESSED} and new_status in {
|
||||||
message.status
|
Message.Status.SENT,
|
||||||
in {Message.Status.BOUNCED, Message.Status.SUPPRESSED}
|
Message.Status.DELIVERED,
|
||||||
and new_status == Message.Status.DELIVERED
|
Message.Status.OPENED,
|
||||||
):
|
Message.Status.CLICKED,
|
||||||
|
}:
|
||||||
return
|
return
|
||||||
|
|
||||||
fields = ["status", "updated_at"]
|
fields = ["status", "updated_at"]
|
||||||
@@ -219,7 +240,7 @@ def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "")
|
|||||||
if error:
|
if error:
|
||||||
message.error = error[:2000]
|
message.error = error[:2000]
|
||||||
fields.append("error")
|
fields.append("error")
|
||||||
elif new_status == Message.Status.DELIVERED:
|
elif new_status in _ENGAGED_OR_DELIVERED:
|
||||||
message.error = ""
|
message.error = ""
|
||||||
fields.append("error")
|
fields.append("error")
|
||||||
message.save(update_fields=fields)
|
message.save(update_fields=fields)
|
||||||
@@ -293,7 +314,17 @@ def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) ->
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# open / click / resubscribe — event row only (status unchanged)
|
if event == "open":
|
||||||
|
# Open implies delivery; do not overwrite a stronger click status.
|
||||||
|
if message.status != Message.Status.CLICKED:
|
||||||
|
_maybe_upgrade_status(message, Message.Status.OPENED)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event == "click":
|
||||||
|
_maybe_upgrade_status(message, Message.Status.CLICKED)
|
||||||
|
return
|
||||||
|
|
||||||
|
# resubscribe — event row only (status unchanged)
|
||||||
|
|
||||||
|
|
||||||
def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | None:
|
def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | None:
|
||||||
@@ -612,7 +643,20 @@ def process_pcm_postcard_webhook(payload: dict[str, Any]) -> ProviderEvent | Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def campaign_engagement_stats(campaign) -> dict[str, int]:
|
def _engagement_chart_bars(metrics: list[tuple[str, int]]) -> list[dict]:
|
||||||
|
"""Build bar heights (percent) for the campaign engagement chart."""
|
||||||
|
peak = max((value for _, value in metrics), default=0)
|
||||||
|
bars: list[dict] = []
|
||||||
|
for label, value in metrics:
|
||||||
|
if peak <= 0:
|
||||||
|
pct = 12 if value == 0 else 100
|
||||||
|
else:
|
||||||
|
pct = max(12, int(round((value / peak) * 100))) if value else 8
|
||||||
|
bars.append({"label": label, "value": value, "pct": pct})
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_engagement_stats(campaign) -> dict:
|
||||||
"""Aggregate delivery + open/click counts for the campaign report."""
|
"""Aggregate delivery + open/click counts for the campaign report."""
|
||||||
messages_qs = campaign.messages.all()
|
messages_qs = campaign.messages.all()
|
||||||
statuses = list(messages_qs.values_list("status", flat=True))
|
statuses = list(messages_qs.values_list("status", flat=True))
|
||||||
@@ -626,15 +670,43 @@ def campaign_engagement_stats(campaign) -> dict[str, int]:
|
|||||||
events.filter(event_type__iexact="click").values_list("message_id", flat=True)
|
events.filter(event_type__iexact="click").values_list("message_id", flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Status-based engagement also counts (webhook may set opened/clicked).
|
||||||
|
status_opened = sum(1 for s in statuses if s in {"opened", "clicked"})
|
||||||
|
status_clicked = sum(1 for s in statuses if s == "clicked")
|
||||||
|
opens = max(len(open_message_ids), status_opened)
|
||||||
|
clicks = max(len(click_message_ids), status_clicked)
|
||||||
|
|
||||||
|
sent = sum(1 for s in statuses if s in _SENT_OR_BETTER)
|
||||||
|
delivered = sum(1 for s in statuses if s in _ENGAGED_OR_DELIVERED)
|
||||||
|
failed = sum(1 for s in statuses if s in {"failed", "bounced"})
|
||||||
|
suppressed = sum(1 for s in statuses if s == "suppressed")
|
||||||
|
|
||||||
|
if campaign.channel == Channel.EMAIL:
|
||||||
|
chart_metrics = [
|
||||||
|
("Sent", sent),
|
||||||
|
("Delivered", delivered),
|
||||||
|
("Opens", opens),
|
||||||
|
("Clicks", clicks),
|
||||||
|
("Failed", failed),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
chart_metrics = [
|
||||||
|
("Sent", sent),
|
||||||
|
("Delivered", delivered),
|
||||||
|
("Failed", failed),
|
||||||
|
("Suppressed", suppressed),
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": len(statuses),
|
"total": len(statuses),
|
||||||
"sent": sum(1 for s in statuses if s in {"sent", "delivered"}),
|
"sent": sent,
|
||||||
"delivered": sum(1 for s in statuses if s == "delivered"),
|
"delivered": delivered,
|
||||||
"failed": sum(1 for s in statuses if s in {"failed", "bounced"}),
|
"failed": failed,
|
||||||
"bounced": sum(1 for s in statuses if s == "bounced"),
|
"bounced": sum(1 for s in statuses if s == "bounced"),
|
||||||
"suppressed": sum(1 for s in statuses if s == "suppressed"),
|
"suppressed": suppressed,
|
||||||
"opens": len(open_message_ids),
|
"opens": opens,
|
||||||
"clicks": len(click_message_ids),
|
"clicks": clicks,
|
||||||
"open_events": events.filter(event_type__iexact="open").count(),
|
"open_events": events.filter(event_type__iexact="open").count(),
|
||||||
"click_events": events.filter(event_type__iexact="click").count(),
|
"click_events": events.filter(event_type__iexact="click").count(),
|
||||||
|
"chart_bars": _engagement_chart_bars(chart_metrics),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ body.portal {
|
|||||||
.badge-sent { background: #d0eef0; color: #00626c; }
|
.badge-sent { background: #d0eef0; color: #00626c; }
|
||||||
.badge-scheduled { background: #ede9fe; color: #5b21b6; }
|
.badge-scheduled { background: #ede9fe; color: #5b21b6; }
|
||||||
.badge-delivered { background: #d1fae5; color: #065f46; }
|
.badge-delivered { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-opened { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-clicked { background: #ede9fe; color: #5b21b6; }
|
||||||
.badge-failed { background: #fee2e2; color: #991b1b; }
|
.badge-failed { background: #fee2e2; color: #991b1b; }
|
||||||
.badge-optin { background: #d1fae5; color: #065f46; }
|
.badge-optin { background: #d1fae5; color: #065f46; }
|
||||||
.badge-optout { background: #f3f4f6; color: #4b5563; }
|
.badge-optout { background: #f3f4f6; color: #4b5563; }
|
||||||
@@ -199,15 +201,52 @@ body.portal {
|
|||||||
border: 1px dashed #a8d8dc;
|
border: 1px dashed #a8d8dc;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
padding: 24px 16px 16px;
|
padding: 24px 16px 12px;
|
||||||
}
|
}
|
||||||
.chart-placeholder .bar {
|
.chart-placeholder > .bar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--monica-primary);
|
background: var(--monica-primary);
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
border-radius: 2px 2px 0 0;
|
border-radius: 2px 2px 0 0;
|
||||||
min-height: 20px;
|
min-height: 8px;
|
||||||
|
}
|
||||||
|
.chart-placeholder .chart-bar-col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.chart-placeholder .chart-bar-col .bar {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--monica-primary);
|
||||||
|
opacity: 0.75;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
min-height: 8px;
|
||||||
|
transition: height 0.25s ease;
|
||||||
|
}
|
||||||
|
.chart-placeholder .chart-bar-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.chart-placeholder .chart-bar-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.chart-placeholder .chart-bar-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--monica-muted);
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
|
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
|||||||
Reference in New Issue
Block a user