Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions backend/app/services/insights/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from datetime import datetime
from typing import Any
from urllib.parse import urlsplit

from backend.app.models.insights import InsightResponse, InsightShareResponse, InsightWindow
from backend.app.services.insights.aggregator import InsightAggregator
Expand Down Expand Up @@ -129,6 +130,9 @@ def _response_with_expiry(self, entry: CacheEntry) -> InsightResponse:
def _build_share_url(self, token: str) -> str:
if not self._share_base_url:
raise RuntimeError("Share base URL is not configured")

base = self._share_base_url.rstrip("/")
return f"{base}/insights/share/{token}"
parsed = urlsplit(self._share_base_url)
if parsed.scheme and parsed.netloc:
origin = f"{parsed.scheme}://{parsed.netloc}"
else:
origin = self._share_base_url.rstrip("/")
return f"{origin}/share/{token}"
15 changes: 15 additions & 0 deletions backend/tests/integration/test_insights_share.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,18 @@ async def test_share_token_round_trip(tmp_path) -> None:
finally:
app.dependency_overrides.clear()
await store.close()


@pytest.mark.asyncio
async def test_share_url_strips_path_component(tmp_path) -> None:
database_url = f"sqlite+aiosqlite:///{tmp_path/'insights_share_path.db'}"
service = InsightService(
database_url=database_url,
cache_ttl_seconds=30,
share_token_salt="test-share-salt",
share_base_url="https://clipnotes.example.com/insights",
)

url = service._build_share_url("abc123")

assert url == "https://clipnotes.example.com/share/abc123"
84 changes: 51 additions & 33 deletions frontend/src/components/SessionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,34 +118,38 @@ export function SessionHistory({
: "border-border-glass/75 bg-surface-glass/70 text-text-secondary hover:border-accent-primary/45 hover:text-text-primary",
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-semibold text-text-primary">
<span aria-hidden="true" className="filename-ext" data-ext={extension}>
{base}
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-semibold text-text-primary">
<span aria-hidden="true" className="filename-ext" data-ext={extension}>
{base}
</span>
<span className="sr-only">{accessibleName}</span>
</p>
<p className="text-xs text-text-secondary/80">Registered {formatTimestamp(clip.created_at)}</p>
</div>
<div className="flex flex-col items-end gap-1 text-xs text-text-secondary/80 text-right">
<span className="rounded-full border border-border-glass/80 px-3 py-0.5 text-[0.7rem] font-semibold uppercase tracking-wide text-text-primary">
{clip.status}
</span>
<span className="sr-only">{accessibleName}</span>
</p>
<p className="text-xs text-text-secondary/85">Registered {formatTimestamp(clip.created_at)}</p>
{clip.last_analysis_at ? (
<p className="text-text-secondary/75">Last analysis {formatTimestamp(clip.last_analysis_at)}</p>
) : (
<p className="text-text-secondary/70">Awaiting analysis</p>
)}
{typeof clip.latency_ms === "number" ? (
<p className="text-text-secondary/75">Latency {clip.latency_ms} ms</p>
) : null}
</div>
</div>
<div className="flex flex-col items-end gap-1 text-xs text-text-secondary">
<span className="rounded-full border border-border-glass/80 px-3 py-0.5 text-[0.7rem] font-semibold uppercase tracking-wide text-text-primary">
{clip.status}
</span>
{clip.last_analysis_at ? (
<p className="text-text-secondary/85">Last analysis {formatTimestamp(clip.last_analysis_at)}</p>
) : (
<p className="text-text-secondary/70">Awaiting analysis</p>
)}
{typeof clip.latency_ms === "number" ? (
<p className="text-text-secondary/85">Latency {clip.latency_ms} ms</p>
) : null}
<div className="flex justify-end">
<button
type="button"
onClick={() => {
void onSelect(clip.clip_id);
}}
className="mt-1 rounded-full border border-border-glass px-3 py-1 text-xs font-semibold text-text-primary transition hover:border-accent-primary hover:text-text-accent"
className="rounded-full border border-border-glass px-3 py-1 text-xs font-semibold text-text-primary transition hover:border-accent-primary hover:text-text-accent"
>
View summary
</button>
Expand Down Expand Up @@ -178,20 +182,34 @@ export function SessionHistory({
: "border-border-glass/75 bg-surface-glass/70 text-text-secondary hover:border-accent-primary/45 hover:text-text-primary",
)}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p
className="font-semibold text-text-primary"
title={session.registeredFileName ?? session.fileName ?? undefined}
>
<span aria-hidden="true" className="filename-ext" data-ext={extension}>
{base}
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p
className="font-semibold text-text-primary"
title={session.registeredFileName ?? session.fileName ?? undefined}
>
<span aria-hidden="true" className="filename-ext" data-ext={extension}>
{base}
</span>
<span className="sr-only">{accessibleName}</span>
</p>
<p className="text-xs text-text-secondary/80">Updated {lastUpdated}</p>
</div>
<div className="flex flex-col items-end gap-1 text-xs text-text-secondary/80 text-right">
<span className="rounded-full border border-border-glass/80 px-3 py-0.5 text-[0.7rem] font-semibold uppercase tracking-wide text-text-primary">
{session.chats.length ? `${session.chats.length} chat${session.chats.length > 1 ? "s" : ""}` : "No chats yet"}
</span>
<span className="sr-only">{accessibleName}</span>
</p>
<p className="text-xs text-text-secondary/85">Updated {lastUpdated}</p>
{session.isChatting ? (
<p className="text-text-secondary/75">Preparing response…</p>
) : session.chatError ? (
<p className="text-rose-200">Follow-up failed</p>
) : (
<p className="text-text-secondary/70">Ready for follow-ups</p>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={() => {
Expand Down
47 changes: 26 additions & 21 deletions frontend/src/components/insights/InsightsReportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,29 +42,33 @@ export function InsightsReportDialog({ insight, isLoading }: InsightsReportDialo
const isDisabled = isLoading || !insight || isExporting;

return (
<section className="rounded-2xl border border-border-glass/60 bg-surface-panel/60 p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.28em] text-text-secondary/70">Stakeholder report</p>
<h2 className="text-lg font-semibold text-text-primary">Download printable insight summary</h2>
<p className="text-sm text-text-secondary/80">
Open a dedicated report window optimised for printing or PDF export so you can brief stakeholders without dashboard access.
</p>
<dl className="grid grid-cols-1 gap-2 text-xs text-text-secondary/70 sm:grid-cols-2">
<div>
<section className="rounded-3xl border border-border-glass/70 bg-surface-glass/80 p-6 shadow-glass">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-2xl space-y-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.3em] text-accent-primary/75">Stakeholder report</p>
<h2 className="text-xl font-semibold text-text-primary">Download printable insight summary</h2>
<p className="text-sm leading-6 text-text-secondary/80">
Open a dedicated report view optimised for printing or PDF export so you can brief stakeholders without navigating the dashboard.
</p>
</div>
<dl className="grid grid-cols-1 gap-3 text-xs text-text-secondary/70 sm:grid-cols-3">
<div className="rounded-2xl border border-border-glass/60 bg-surface-panel/60 p-3">
<dt className="font-semibold uppercase tracking-[0.12em] text-text-secondary/60">Generated</dt>
<dd>{generatedLabel ?? "Pending"}</dd>
<dd className="mt-1 text-sm text-text-primary/85">{generatedLabel ?? "Pending"}</dd>
</div>
<div>
<div className="rounded-2xl border border-border-glass/60 bg-surface-panel/60 p-3">
<dt className="font-semibold uppercase tracking-[0.12em] text-text-secondary/60">Cache refresh</dt>
<dd>{cacheRefreshLabel ?? "Refresh scheduled"}</dd>
<dd className="mt-1 text-sm text-text-primary/85">{cacheRefreshLabel ?? "Refresh scheduled"}</dd>
</div>
<div className="rounded-2xl border border-border-glass/60 bg-surface-panel/60 p-3">
<dt className="font-semibold uppercase tracking-[0.12em] text-text-secondary/60">Top focus</dt>
<dd className="mt-1 text-sm text-text-primary/85">{focusPreview}</dd>
</div>
</dl>
<div className="rounded-xl border border-border-glass/50 bg-surface-glass/40 p-3 text-sm text-text-secondary/80">
<div className="rounded-2xl border border-border-glass/70 bg-surface-panel/70 p-4 text-sm text-text-secondary/80">
<p className="font-semibold text-text-primary/90">Summary preview</p>
<p>{summaryPreview}</p>
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-text-secondary/60">Top focus</p>
<p className="text-sm">{focusPreview}</p>
<p className="mt-2 leading-6">{summaryPreview}</p>
</div>
</div>

Expand Down Expand Up @@ -95,15 +99,16 @@ export function InsightsReportDialog({ insight, isLoading }: InsightsReportDialo
}
}}
className={cn(
"inline-flex items-center justify-center rounded-full border border-border-glass/70 px-4 py-2 text-sm font-semibold transition",
"bg-accent-primary/20 text-accent-primary hover:bg-accent-primary/30",
isDisabled && "opacity-70",
"inline-flex items-center justify-center rounded-full border border-accent-primary/40 px-5 py-2.5 text-sm font-semibold transition",
isDisabled
? "bg-accent-primary/15 text-accent-primary/60"
: "bg-accent-primary/25 text-accent-primary hover:bg-accent-primary/35",
)}
disabled={isDisabled}
>
{isExporting ? "Preparing report..." : "Download stakeholder report"}
</button>
<p className="text-xs text-text-secondary/70">
<p className="text-xs leading-5 text-text-secondary/70">
The report opens in a new browser tab. Use your browser print dialog to save it as PDF or send it to stakeholders.
</p>
</div>
Expand Down
26 changes: 20 additions & 6 deletions frontend/src/components/insights/InsightsToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@ interface InsightsToolbarProps {
generatedAt?: string | null;
cacheExpiresAt?: string | null;
}

const WINDOW_OPTIONS: Array<{ value: InsightWindow; label: string; description: string }> = [
{ value: "24h", label: "24 hours", description: "Hourly trend" },
{ value: "7d", label: "7 days", description: "Daily trend" },
const WINDOW_OPTIONS: Array<{ value: InsightWindow; label: string; description: string; disabled?: boolean }> = [
{
value: "24h",
label: "24 hours",
description: "Currently showing the last 24 hours while we polish the weekly view.",
},
{
value: "7d",
label: "7 days",
description: "Daily trend (coming soon)",
disabled: true,
},
];

function formatTimestamp(value: string | null | undefined): string | null {
Expand Down Expand Up @@ -58,14 +66,20 @@ export function InsightsToolbar({
"rounded-full px-3 py-1.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary/40",
option.value === window
? "bg-accent-primary/20 text-accent-primary"
: "text-text-secondary hover:text-text-primary",
: option.disabled
? "text-text-secondary/50"
: "text-text-secondary hover:text-text-primary",
)}
onClick={() => {
if (option.disabled) {
return;
}
if (option.value !== window) {
onWindowChange(option.value);
}
}}
disabled={isLoading || isRegenerating}
disabled={isLoading || isRegenerating || option.disabled}
title={option.disabled ? "7-day insights are coming soon" : undefined}
>
{option.label}
</button>
Expand Down