mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(tg): premium Mini App cockpit — spend hero, charts, avatars (#582)
* feat(tg): premium Today — spend hero, trend, quick actions, fleet avatars The cockpit home stops being flat cards and becomes a real app surface: - Spend HERO: the day's cost at 40px with a signed delta-vs-yesterday chip and a live 7-day amber area sparkline (hand-rolled inline SVG, no charting lib in the Mini App bundle). - Quick-action ring: circular Approve (amber + needs-you badge) / Board / Inbox / Chat, the wallet-style primary-verb row. - Needs-you as a rich amber gradient banner (top items + draft chips) instead of a plain section. - Fleet as live avatar tokens (stable per-name hue, pulse dot) over the working list. - "Shipped this week" day-bars (today emphasized) + week total. Backend: /telegram/today gains spend.series (7-day cost) + delta_pct and a velocity series (per-day completed tasks) — two cheap grouped-by-day queries, same DB-only ethos, degrading to zeros on error. * feat(tg): color-code approval rows by kind TgRowIcon gains a tone prop; the approvals list tints each tile per kind (amber Release / sky X post / violet Video / emerald Roadmap) so a mixed queue reads as color-coded instead of a monochrome column. * feat(tg): sender/peer avatars on Inbox + Chat Inbox notifications and Chat conversation rows adopt the fleet-avatar language: a per-name-hued initials token leads each card, unread inbox items carry a subtle primary tint, and both cards move to the rounded-2xl surface — so every tab now shares one visual system. Board keeps the shared MobileTaskBoard (already grouped/pill-styled, and reused outside the cockpit). --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Hand-rolled inline-SVG charts for the cockpit — no charting library in the
|
||||
* Mini App bundle. Both are theme-driven (stroke/fill ride `currentColor`,
|
||||
* so the caller sets the hue via a text color) and degrade to a flat
|
||||
* baseline for an all-zero series rather than dividing by zero.
|
||||
*/
|
||||
|
||||
const SPARK_W = 300;
|
||||
const SPARK_H = 72;
|
||||
|
||||
/** Smooth-ish area sparkline with a gradient fill and an emphasized last
|
||||
* point — the hero's spend trend. `values` oldest → newest. */
|
||||
export function Sparkline({ values }: { values: number[] }) {
|
||||
const n = values.length;
|
||||
const max = Math.max(...values, 0);
|
||||
const min = Math.min(...values, 0);
|
||||
const span = max - min || 1;
|
||||
const gradId = "tg-spark-grad";
|
||||
|
||||
const x = (i: number) => (n <= 1 ? 0 : (i / (n - 1)) * SPARK_W);
|
||||
// Leave 6px headroom top/bottom so the stroke + endpoint dot never clip.
|
||||
const y = (v: number) => SPARK_H - 6 - ((v - min) / span) * (SPARK_H - 12);
|
||||
|
||||
const points = values.map((v, i) => [x(i), y(v)] as const);
|
||||
const line = points.map(([px, py]) => `${px},${py}`).join(" ");
|
||||
const area = `M0,${SPARK_H} L${line.replace(/ /g, " L")} L${SPARK_W},${SPARK_H} Z`;
|
||||
const [lastX, lastY] = points[points.length - 1] ?? [SPARK_W, SPARK_H / 2];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${SPARK_W} ${SPARK_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-16 w-full overflow-visible text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill={`url(#${gradId})`} />
|
||||
<polyline
|
||||
points={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle cx={lastX} cy={lastY} r="3.5" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact day bars — the last bar (today) emphasized in the accent, the
|
||||
* rest muted. `values` oldest → newest. */
|
||||
export function DayBars({
|
||||
values,
|
||||
labels,
|
||||
}: {
|
||||
values: number[];
|
||||
labels?: string[];
|
||||
}) {
|
||||
const max = Math.max(...values, 1);
|
||||
return (
|
||||
<div className="flex items-end gap-1.5" aria-hidden="true">
|
||||
{values.map((v, i) => {
|
||||
const isToday = i === values.length - 1;
|
||||
const pct = Math.round((v / max) * 100);
|
||||
return (
|
||||
<div key={i} className="flex flex-1 flex-col items-center gap-1">
|
||||
<div className="flex h-14 w-full items-end">
|
||||
<div
|
||||
className={`w-full rounded-sm ${
|
||||
isToday ? "bg-primary" : "bg-muted-foreground/25"
|
||||
}`}
|
||||
style={{ height: `${Math.max(pct, v > 0 ? 8 : 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{labels && (
|
||||
<span className="text-[9px] tabular-nums text-muted-foreground/60">
|
||||
{labels[i]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user