fix: fix fact the app stopped working for goodness sake
This commit is contained in:
+100
-249
@@ -2,13 +2,11 @@ import type { Models } from "appwrite";
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Brain,
|
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Camera,
|
Camera,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Cloud,
|
Cloud,
|
||||||
Command,
|
Command,
|
||||||
Database,
|
|
||||||
Edit3,
|
Edit3,
|
||||||
FileJson,
|
FileJson,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
@@ -16,19 +14,15 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
LineChart,
|
LineChart,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
|
||||||
LogIn,
|
LogIn,
|
||||||
LogOut,
|
LogOut,
|
||||||
Plus,
|
Plus,
|
||||||
PoundSterling,
|
PoundSterling,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Send,
|
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
Settings2,
|
||||||
ShieldCheck,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Square,
|
|
||||||
TimerReset,
|
TimerReset,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -86,6 +80,7 @@ import {
|
|||||||
} from "./lib/appwriteEntries";
|
} from "./lib/appwriteEntries";
|
||||||
import { BarcodeScannerModal } from "./components/BarcodeScannerModal";
|
import { BarcodeScannerModal } from "./components/BarcodeScannerModal";
|
||||||
import { DailyLimitsCard } from "./components/DailyLimitsCard";
|
import { DailyLimitsCard } from "./components/DailyLimitsCard";
|
||||||
|
import { LegalFootnote } from "./components/LegalFootnote";
|
||||||
import { LimitsSettingsForm } from "./components/LimitsSettingsForm";
|
import { LimitsSettingsForm } from "./components/LimitsSettingsForm";
|
||||||
import { OnboardingScreen } from "./components/OnboardingScreen";
|
import { OnboardingScreen } from "./components/OnboardingScreen";
|
||||||
import { buildDynamicGreeting } from "./lib/greeting";
|
import { buildDynamicGreeting } from "./lib/greeting";
|
||||||
@@ -123,16 +118,21 @@ import {
|
|||||||
wholeNumber,
|
wholeNumber,
|
||||||
} from "./lib/metrics";
|
} from "./lib/metrics";
|
||||||
import { exportPayload, parseImport } from "./lib/storage";
|
import { exportPayload, parseImport } from "./lib/storage";
|
||||||
import type { CoachChat, CoachMessage, DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types";
|
import type {
|
||||||
|
DateFilter,
|
||||||
|
EntryDraft,
|
||||||
|
Filters,
|
||||||
|
Flavour,
|
||||||
|
ImportPreview,
|
||||||
|
LimitCheckResult,
|
||||||
|
RedBullEntry,
|
||||||
|
UserLimits,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
type AppView = "overview" | "logbook" | "trends" | "settings";
|
type AppView = "overview" | "logbook" | "trends" | "settings";
|
||||||
type AuthMode = "login" | "signup";
|
type AuthMode = "login" | "signup";
|
||||||
type AuthUser = Models.User<Models.Preferences>;
|
type AuthUser = Models.User<Models.Preferences>;
|
||||||
type SetupStatus = { state: "checking" | "ok" | "error"; message: string };
|
type SetupStatus = { state: "checking" | "ok" | "error"; message: string };
|
||||||
type OllamaStreamChunk = { error?: string; message?: { content?: string; thinking?: string } };
|
|
||||||
const OLLAMA_MODEL = "deepseek-v4-pro:cloud";
|
|
||||||
const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat";
|
|
||||||
|
|
||||||
type ForecastPoint = {
|
type ForecastPoint = {
|
||||||
label: string;
|
label: string;
|
||||||
current: number;
|
current: number;
|
||||||
@@ -140,6 +140,21 @@ type ForecastPoint = {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingLimitAction = {
|
||||||
|
kind: "save" | "quick";
|
||||||
|
draft: EntryDraft;
|
||||||
|
editingId?: string;
|
||||||
|
quickLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MATERIAL_ACCENTS = {
|
||||||
|
primary: "var(--chart-primary)",
|
||||||
|
secondary: "var(--chart-secondary)",
|
||||||
|
tertiary: "var(--chart-tertiary)",
|
||||||
|
error: "var(--chart-error)",
|
||||||
|
custom: "#b85d84",
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_FILTERS: Filters = {
|
const DEFAULT_FILTERS: Filters = {
|
||||||
flavour: "all",
|
flavour: "all",
|
||||||
dateRange: "all",
|
dateRange: "all",
|
||||||
@@ -379,7 +394,7 @@ function App() {
|
|||||||
setIsEntryModalOpen(true);
|
setIsEntryModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEntry(draft: EntryDraft) {
|
async function saveUserLimits(next: UserLimits) {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
setBusyAction("save-limits");
|
setBusyAction("save-limits");
|
||||||
setSyncError("");
|
setSyncError("");
|
||||||
@@ -433,9 +448,9 @@ function App() {
|
|||||||
? await updateEntry(user.$id, editing.id, { ...action.draft, source: editing.source })
|
? await updateEntry(user.$id, editing.id, { ...action.draft, source: editing.source })
|
||||||
: await createEntry(user.$id, { ...action.draft, source: action.draft.source ?? "manual" });
|
: await createEntry(user.$id, { ...action.draft, source: action.draft.source ?? "manual" });
|
||||||
setEntries((current) =>
|
setEntries((current) =>
|
||||||
sortEntries(editingEntry ? current.map((entry) => (entry.id === saved.id ? saved : entry)) : [saved, ...current]),
|
sortEntries(editing ? current.map((entry) => (entry.id === saved.id ? saved : entry)) : [saved, ...current]),
|
||||||
);
|
);
|
||||||
setNotice(editingEntry ? "Entry updated in Appwrite." : "Entry saved to Appwrite.");
|
setNotice(editing ? "Entry updated in Appwrite." : "Entry saved to Appwrite.");
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
setEntryInitialDraft(null);
|
setEntryInitialDraft(null);
|
||||||
setIsEntryModalOpen(false);
|
setIsEntryModalOpen(false);
|
||||||
@@ -481,16 +496,12 @@ function App() {
|
|||||||
source: "quick-add",
|
source: "quick-add",
|
||||||
};
|
};
|
||||||
|
|
||||||
setActionLoading(`quick-${item.label}`);
|
const check = evaluateLimits(userLimits, entries, { draft });
|
||||||
setDataError("");
|
if (check.violations.length) {
|
||||||
try {
|
setPendingLimitAction({ kind: "quick", draft, quickLabel: item.label });
|
||||||
const saved = await createEntry(user.$id, draft);
|
setLimitConfirmMessage(limitStatusMessage(check.violations, check, userLimits));
|
||||||
setEntries((current) => sortEntries([saved, ...current]));
|
setLimitConfirmOpen(true);
|
||||||
setNotice(`${item.label} saved to Appwrite.`);
|
return;
|
||||||
} catch (error) {
|
|
||||||
setDataError(appwriteErrorMessage(error));
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void saveDraft({ kind: "quick", draft, quickLabel: item.label });
|
void saveDraft({ kind: "quick", draft, quickLabel: item.label });
|
||||||
@@ -664,7 +675,7 @@ function App() {
|
|||||||
|
|
||||||
<ShellBackdrop />
|
<ShellBackdrop />
|
||||||
|
|
||||||
<div className="mx-auto grid w-full max-w-[1680px] gap-4 px-3 py-3 lg:grid-cols-[280px_1fr] lg:px-5 lg:py-5">
|
<div className="app-layout">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
dataLoading={dataLoading}
|
dataLoading={dataLoading}
|
||||||
@@ -677,11 +688,8 @@ function App() {
|
|||||||
onOpenSettings={() => setActiveView("settings")}
|
onOpenSettings={() => setActiveView("settings")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="app-content">
|
||||||
<MobileNav activeView={activeView} onChange={setActiveView} />
|
|
||||||
|
|
||||||
<TopBar
|
<TopBar
|
||||||
activeTheme={activeTheme}
|
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
busyAction={busyAction}
|
busyAction={busyAction}
|
||||||
onAdd={openNewEntry}
|
onAdd={openNewEntry}
|
||||||
@@ -698,7 +706,7 @@ function App() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -8 }}
|
exit={{ opacity: 0, y: -8 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="mt-4"
|
className="app-view"
|
||||||
>
|
>
|
||||||
{activeView === "overview" && (
|
{activeView === "overview" && (
|
||||||
<OverviewView
|
<OverviewView
|
||||||
@@ -716,6 +724,7 @@ function App() {
|
|||||||
onAdd={openNewEntry}
|
onAdd={openNewEntry}
|
||||||
onScan={openBarcodeScanner}
|
onScan={openBarcodeScanner}
|
||||||
onOpenLogbook={() => setActiveView("logbook")}
|
onOpenLogbook={() => setActiveView("logbook")}
|
||||||
|
onOpenSettings={() => setActiveView("settings")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -744,7 +753,9 @@ function App() {
|
|||||||
entries={entriesInView}
|
entries={entriesInView}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
flavours={allFlavours}
|
flavours={allFlavours}
|
||||||
|
userLimits={userLimits}
|
||||||
onFilterChange={setFilters}
|
onFilterChange={setFilters}
|
||||||
|
onSaveLimits={(next) => void saveUserLimits(next)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -777,14 +788,14 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MobileNav activeView={activeView} onChange={setActiveView} />
|
||||||
|
|
||||||
<EntryModal
|
<EntryModal
|
||||||
entry={editingEntry}
|
entry={editingEntry}
|
||||||
initialDraft={entryInitialDraft}
|
initialDraft={entryInitialDraft}
|
||||||
flavours={allFlavours}
|
flavours={allFlavours}
|
||||||
open={isEntryModalOpen}
|
open={isEntryModalOpen}
|
||||||
saving={busyAction === "save-entry"}
|
saving={busyAction === "save-entry"}
|
||||||
userLimits={userLimits}
|
|
||||||
entries={entries}
|
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsEntryModalOpen(false);
|
setIsEntryModalOpen(false);
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
@@ -957,6 +968,7 @@ function AuthView({
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<LegalFootnote className="mt-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -1028,7 +1040,7 @@ function Sidebar({
|
|||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<aside className="glass-panel sticky top-5 hidden h-[calc(100vh-2.5rem)] p-3 lg:flex lg:flex-col">
|
<aside className="material-drawer glass-panel">
|
||||||
<div className="mb-7 flex items-center gap-3 px-2 pt-1">
|
<div className="mb-7 flex items-center gap-3 px-2 pt-1">
|
||||||
<div className="can-emblem">
|
<div className="can-emblem">
|
||||||
<Command size={22} aria-hidden="true" />
|
<Command size={22} aria-hidden="true" />
|
||||||
@@ -1065,6 +1077,8 @@ function Sidebar({
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<LegalFootnote className="mb-3 px-1" />
|
||||||
|
|
||||||
<div className="drawer-footer">
|
<div className="drawer-footer">
|
||||||
<div className="drawer-info-card">
|
<div className="drawer-info-card">
|
||||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
|
||||||
@@ -1086,14 +1100,12 @@ function Sidebar({
|
|||||||
|
|
||||||
function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (view: AppView) => void }) {
|
function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (view: AppView) => void }) {
|
||||||
return (
|
return (
|
||||||
<nav className="sticky top-3 z-30 mb-3 grid grid-cols-4 gap-1 rounded-lg border border-white/10 bg-[#090f22]/90 p-1 shadow-fridge backdrop-blur-xl lg:hidden" aria-label="Main navigation">
|
<nav className="mobile-nav-bar" aria-label="Main navigation">
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex min-h-11 flex-col items-center justify-center gap-1 rounded-md text-[11px] font-medium transition ${
|
className={`mobile-nav-item ${activeView === item.id ? "mobile-nav-item-active" : ""}`}
|
||||||
activeView === item.id ? "bg-cyan-300 text-[#07101f] shadow-cyan" : "text-slate-300 hover:bg-white/10"
|
|
||||||
}`}
|
|
||||||
onClick={() => onChange(item.id)}
|
onClick={() => onChange(item.id)}
|
||||||
>
|
>
|
||||||
<item.icon size={16} aria-hidden="true" />
|
<item.icon size={16} aria-hidden="true" />
|
||||||
@@ -1105,21 +1117,21 @@ function MobileNav({ activeView, onChange }: { activeView: AppView; onChange: (v
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TopBar({
|
function TopBar({
|
||||||
activeTheme,
|
|
||||||
activeView,
|
activeView,
|
||||||
busyAction,
|
busyAction,
|
||||||
onAdd,
|
onAdd,
|
||||||
onScan,
|
onScan,
|
||||||
className = "",
|
className = "",
|
||||||
}: {
|
}: {
|
||||||
activeTheme: AppTheme;
|
|
||||||
activeView: AppView;
|
activeView: AppView;
|
||||||
busyAction: string | null;
|
busyAction: string | null;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onScan: () => void;
|
onScan: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const title = NAV_ITEMS.find((item) => item.id === activeView)?.label ?? "Overview";
|
const activeItem = NAV_ITEMS.find((item) => item.id === activeView) ?? NAV_ITEMS[0];
|
||||||
|
const ActiveIcon = activeItem.icon;
|
||||||
|
const title = activeItem.label;
|
||||||
const subtitle = new Intl.DateTimeFormat("en-GB", {
|
const subtitle = new Intl.DateTimeFormat("en-GB", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -1215,6 +1227,7 @@ function OverviewView({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onScan,
|
onScan,
|
||||||
onOpenLogbook,
|
onOpenLogbook,
|
||||||
|
onOpenSettings,
|
||||||
}: {
|
}: {
|
||||||
summary: Dashboard;
|
summary: Dashboard;
|
||||||
entries: RedBullEntry[];
|
entries: RedBullEntry[];
|
||||||
@@ -1230,6 +1243,7 @@ function OverviewView({
|
|||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onScan: () => void;
|
onScan: () => void;
|
||||||
onOpenLogbook: () => void;
|
onOpenLogbook: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
}) {
|
}) {
|
||||||
const todaySpendRaw = limitCheck.todaySpend;
|
const todaySpendRaw = limitCheck.todaySpend;
|
||||||
const spendLimitDetail =
|
const spendLimitDetail =
|
||||||
@@ -1245,7 +1259,9 @@ function OverviewView({
|
|||||||
|
|
||||||
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
|
<QuickAddPanel items={quickAdds} onQuickAdd={onQuickAdd} />
|
||||||
|
|
||||||
|
<div className="hidden lg:block">
|
||||||
<TodayPanel summary={summary} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} onScan={onScan} />
|
<TodayPanel summary={summary} entries={entries} userLimits={userLimits} limitCheck={limitCheck} onAdd={onAdd} onScan={onScan} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{limitCheck.violations.length ? (
|
{limitCheck.violations.length ? (
|
||||||
<section className="limit-alert">
|
<section className="limit-alert">
|
||||||
@@ -1342,6 +1358,8 @@ function OverviewView({
|
|||||||
)}
|
)}
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<LegalFootnote className="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1428,14 +1446,25 @@ function WellnessPill({ label, value }: { label: string; value: string }) {
|
|||||||
function TodayPanel({
|
function TodayPanel({
|
||||||
summary,
|
summary,
|
||||||
entries,
|
entries,
|
||||||
|
userLimits,
|
||||||
|
limitCheck,
|
||||||
onAdd,
|
onAdd,
|
||||||
onScan,
|
onScan,
|
||||||
}: {
|
}: {
|
||||||
summary: Dashboard;
|
summary: Dashboard;
|
||||||
entries: RedBullEntry[];
|
entries: RedBullEntry[];
|
||||||
|
userLimits: UserLimits;
|
||||||
|
limitCheck: LimitCheckResult;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onScan: () => void;
|
onScan: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const limitSummary =
|
||||||
|
userLimits.dailyCanLimit != null || userLimits.dailySpendLimit != null
|
||||||
|
? limitCheck.violations.length
|
||||||
|
? limitStatusMessage(limitCheck.violations, limitCheck, userLimits)
|
||||||
|
: `${limitCheck.todayCans} cans · ${currency.format(limitCheck.todaySpend)} spent today`
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="can-panel today-panel relative overflow-hidden p-5 sm:p-7">
|
<section className="can-panel today-panel relative overflow-hidden p-5 sm:p-7">
|
||||||
<p className="section-kicker">Today</p>
|
<p className="section-kicker">Today</p>
|
||||||
@@ -1516,9 +1545,12 @@ function LogbookView({
|
|||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="logbook-layout grid gap-4">
|
<section className="grid gap-4">
|
||||||
|
<div className="logbook-layout grid gap-4">
|
||||||
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} />
|
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} />
|
||||||
<EntryLedger entries={entries} totalEntries={totalEntries} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />
|
<EntryLedger entries={entries} totalEntries={totalEntries} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />
|
||||||
|
</div>
|
||||||
|
<LegalFootnote />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1531,7 +1563,9 @@ function TrendsView({
|
|||||||
entries,
|
entries,
|
||||||
filters,
|
filters,
|
||||||
flavours,
|
flavours,
|
||||||
|
userLimits,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
onSaveLimits,
|
||||||
}: {
|
}: {
|
||||||
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
chartData: Array<{ label: string; spend: number; cans: number; caffeine: number; sugar: number }>;
|
||||||
weekData: Array<{ label: string; spend: number; cans: number }>;
|
weekData: Array<{ label: string; spend: number; cans: number }>;
|
||||||
@@ -1540,7 +1574,9 @@ function TrendsView({
|
|||||||
entries: RedBullEntry[];
|
entries: RedBullEntry[];
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
flavours: Flavour[];
|
flavours: Flavour[];
|
||||||
|
userLimits: UserLimits;
|
||||||
onFilterChange: (filters: Filters) => void;
|
onFilterChange: (filters: Filters) => void;
|
||||||
|
onSaveLimits: (limits: UserLimits) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
@@ -1641,27 +1677,12 @@ function TrendsView({
|
|||||||
onSaveLimits={onSaveLimits}
|
onSaveLimits={onSaveLimits}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<LegalFootnote />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CoachView({ dashboard, entries, user }: { dashboard: Dashboard; entries: RedBullEntry[]; user: AuthUser }) {
|
|
||||||
const [chats, setChats] = useState<CoachChat[]>([]);
|
|
||||||
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
|
||||||
const [savedChatIds, setSavedChatIds] = useState<Set<string>>(() => new Set());
|
|
||||||
const [chatKey, setChatKey] = useState("");
|
|
||||||
const [chatKeyInput, setChatKeyInput] = useState("");
|
|
||||||
const [chatStorageStatus, setChatStorageStatus] = useState("unlock encrypted chat storage");
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [openThinkingIds, setOpenThinkingIds] = useState<string[]>([]);
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const activeChat = chats.find((chat) => chat.id === activeChatId) ?? null;
|
|
||||||
const messages = useMemo(() => activeChat?.messages ?? [], [activeChat]);
|
|
||||||
const visibleMessages = useMemo(() => messages.filter((message) => message.id !== "coach-welcome"), [messages]);
|
|
||||||
|
|
||||||
function SpendForecastCard({
|
function SpendForecastCard({
|
||||||
entries,
|
entries,
|
||||||
userLimits,
|
userLimits,
|
||||||
@@ -1678,72 +1699,28 @@ function SpendForecastCard({
|
|||||||
if (!entries.length) return now;
|
if (!entries.length) return now;
|
||||||
return new Date(
|
return new Date(
|
||||||
[...entries].sort(
|
[...entries].sort(
|
||||||
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime(),
|
||||||
)[0].dateTime
|
)[0].dateTime,
|
||||||
);
|
);
|
||||||
}, [entries, now]);
|
}, [entries, now]);
|
||||||
|
|
||||||
const trackingDays = useMemo(() => {
|
const activePeriodDays = useMemo(() => {
|
||||||
const diffTime = Math.abs(now.getTime() - firstEntryDate.getTime());
|
const diffTime = Math.abs(now.getTime() - firstEntryDate.getTime());
|
||||||
return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
|
return Math.max(1, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
|
||||||
}, [firstEntryDate, now]);
|
}, [firstEntryDate, now]);
|
||||||
|
|
||||||
async function unlockChatStorage(passphrase: string) {
|
const stats = useMemo(() => {
|
||||||
setBusy(true);
|
const periodStart = new Date(now.getTime() - activePeriodDays * 86_400_000);
|
||||||
setError("");
|
const recentEntries = entriesInRange(entries, periodStart, now);
|
||||||
setChatStorageStatus("opening encrypted appwrite chats...");
|
const totalSpend = sum(recentEntries, spendFor);
|
||||||
try {
|
const totalCans = sum(recentEntries, (entry) => entry.cans);
|
||||||
const savedChats = await listEncryptedChats(user.$id, passphrase);
|
const hasData = recentEntries.length > 0;
|
||||||
const initialChats = savedChats.length ? savedChats : [buildNewCoachChat(user)];
|
return {
|
||||||
setChatKey(passphrase);
|
hasData,
|
||||||
setChats(initialChats);
|
avgDailySpend: hasData ? totalSpend / activePeriodDays : 0,
|
||||||
setSavedChatIds(new Set(savedChats.map((chat) => chat.id)));
|
avgDailyCans: hasData ? totalCans / activePeriodDays : 0,
|
||||||
setActiveChatId(initialChats[0].id);
|
};
|
||||||
setChatStorageStatus(savedChats.length ? `${savedChats.length} encrypted chat${savedChats.length === 1 ? "" : "s"} loaded` : "new encrypted chat ready");
|
}, [entries, activePeriodDays, now]);
|
||||||
} catch (caught) {
|
|
||||||
const message = chatStorageErrorMessage(caught);
|
|
||||||
setError(message);
|
|
||||||
setChatKey("");
|
|
||||||
setChatStorageStatus("encrypted chat unlock failed");
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startNewChat() {
|
|
||||||
if (!chatKey) return;
|
|
||||||
const chat = buildNewCoachChat(user);
|
|
||||||
setChats((current) => [chat, ...current]);
|
|
||||||
setActiveChatId(chat.id);
|
|
||||||
setInput("");
|
|
||||||
setError("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
await sendPrompt(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendPrompt(prompt: string) {
|
|
||||||
const trimmed = prompt.trim();
|
|
||||||
if (!trimmed || busy) return;
|
|
||||||
if (!chatKey) {
|
|
||||||
setError("unlock encrypted chat storage first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentChat = activeChat ?? buildNewCoachChat(user);
|
|
||||||
const userMessage: CoachMessage = { id: makeId(), role: "user", content: trimmed };
|
|
||||||
const assistantId = makeId();
|
|
||||||
const assistantMessage: CoachMessage = { id: assistantId, role: "assistant", content: "", thinking: "", pending: true };
|
|
||||||
const conversation = [...currentChat.messages, userMessage];
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
setChats((current) =>
|
|
||||||
current.map((c) => (c.id === currentChat.id ? draftChat : c))
|
|
||||||
);
|
|
||||||
setChatStorageStatus("");
|
|
||||||
setInput("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectionData = useMemo<ForecastPoint[]>(() => {
|
const projectionData = useMemo<ForecastPoint[]>(() => {
|
||||||
return Array.from({ length: projectionDays }).map((_, index) => {
|
return Array.from({ length: projectionDays }).map((_, index) => {
|
||||||
@@ -1894,53 +1871,6 @@ function SpendForecastCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CoachMessageBubble({
|
|
||||||
message,
|
|
||||||
thinkingOpen,
|
|
||||||
onToggleThinking,
|
|
||||||
}: {
|
|
||||||
message: CoachMessage;
|
|
||||||
thinkingOpen: boolean;
|
|
||||||
onToggleThinking: () => void;
|
|
||||||
}) {
|
|
||||||
const isAssistant = message.role === "assistant";
|
|
||||||
const canShowThinking = isAssistant && (message.pending || Boolean(message.thinking));
|
|
||||||
const thinkingLabel = message.stopped ? "stopped thinking" : message.pending ? "thinking" : "thinking";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className={`coach-message coach-message-${message.role}`}>
|
|
||||||
<div className="coach-message-bubble">
|
|
||||||
<p className="text-xs font-semibold uppercase text-slate-500">{isAssistant ? "coach" : "you"}</p>
|
|
||||||
<div className="mt-2 whitespace-pre-wrap text-sm leading-6 text-white">
|
|
||||||
{message.content || (message.pending ? "streaming response..." : "")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canShowThinking && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<button className={`thinking-slider ${message.pending ? "thinking-slider-active" : ""}`} type="button" onClick={onToggleThinking}>
|
|
||||||
<span className="thinking-slider-track">
|
|
||||||
<span>{thinkingLabel} · click to reveal reasoning</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<AnimatePresence>
|
|
||||||
{thinkingOpen && (
|
|
||||||
<motion.pre
|
|
||||||
className="thinking-trace"
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: "auto" }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
>
|
|
||||||
{message.thinking || "waiting for reasoning trace..."}
|
|
||||||
</motion.pre>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsView({
|
function SettingsView({
|
||||||
activeTheme,
|
activeTheme,
|
||||||
summary,
|
summary,
|
||||||
@@ -1960,6 +1890,8 @@ function SettingsView({
|
|||||||
onLogout,
|
onLogout,
|
||||||
onReset,
|
onReset,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
|
onSaveLimits,
|
||||||
|
onRerunOnboarding,
|
||||||
}: {
|
}: {
|
||||||
activeTheme: AppTheme;
|
activeTheme: AppTheme;
|
||||||
summary: Dashboard;
|
summary: Dashboard;
|
||||||
@@ -1979,6 +1911,8 @@ function SettingsView({
|
|||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onThemeChange: (id: string) => void;
|
onThemeChange: (id: string) => void;
|
||||||
|
onSaveLimits: (limits: UserLimits) => void;
|
||||||
|
onRerunOnboarding: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
|
<div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
|
||||||
@@ -2075,6 +2009,8 @@ function SettingsView({
|
|||||||
</button>
|
</button>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LegalFootnote className="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2474,7 +2410,6 @@ function EntryModal({
|
|||||||
source: entry?.source ?? initialDraft?.source ?? "manual",
|
source: entry?.source ?? initialDraft?.source ?? "manual",
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
open,
|
|
||||||
cans,
|
cans,
|
||||||
pricePerCan,
|
pricePerCan,
|
||||||
isOther,
|
isOther,
|
||||||
@@ -2492,11 +2427,6 @@ function EntryModal({
|
|||||||
initialDraft?.source,
|
initialDraft?.source,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const draftLimitCheck = useMemo(() => {
|
|
||||||
if (!draftPreview) return null;
|
|
||||||
return evaluateLimits(userLimits, entries, { draft: draftPreview, excludeEntryId: entry?.id });
|
|
||||||
}, [draftPreview, entries, entry?.id, userLimits]);
|
|
||||||
|
|
||||||
function submit(event: FormEvent<HTMLFormElement>) {
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!draftPreview) return;
|
if (!draftPreview) return;
|
||||||
@@ -2940,84 +2870,6 @@ function formatMetricValue(name: string, value: number) {
|
|||||||
return oneDecimal.format(value);
|
return oneDecimal.format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readOllamaStream(stream: ReadableStream<Uint8Array>, onChunk: (chunk: OllamaStreamChunk) => void) {
|
|
||||||
const reader = stream.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
function processLine(line: string) {
|
|
||||||
const chunk = parseOllamaLine(line);
|
|
||||||
if (chunk) onChunk(chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() ?? "";
|
|
||||||
lines.forEach(processLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
if (buffer.trim()) processLine(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOllamaLine(line: string): OllamaStreamChunk | null {
|
|
||||||
const trimmed = line.trim().replace(/^data:\s*/, "");
|
|
||||||
if (!trimmed || trimmed === "[DONE]") return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(trimmed) as OllamaStreamChunk;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCoachSystemPrompt(user: AuthUser, dashboard: Dashboard, entries: RedBullEntry[]) {
|
|
||||||
const recent = entries
|
|
||||||
.slice(0, 12)
|
|
||||||
.map(
|
|
||||||
(entry) =>
|
|
||||||
`- ${humanDateTime(entry.dateTime)}: ${entry.cans} can(s), ${entry.flavour}, ${entry.sizeMl}ml, ${currency.format(spendFor(entry))}, ${wholeNumber.format(caffeineFor(entry))}mg caffeine, ${oneDecimal.format(sugarFor(entry))}g sugar`,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return [
|
|
||||||
"You are an upbeat Red Bull intake coach inside a tracking app.",
|
|
||||||
"Respond entirely in lower case, including headings and short labels.",
|
|
||||||
"Give concise, practical suggestions based only on the logged data provided.",
|
|
||||||
"Do not give medical advice; suggest checking labels and using personal judgement for caffeine tolerance.",
|
|
||||||
`User: ${user.name || user.email || "Appwrite user"}`,
|
|
||||||
`Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`,
|
|
||||||
`Favourite flavour: ${dashboard.favouriteFlavour}. Current streak: ${dashboard.currentStreak} day(s). Total spend: ${dashboard.totalSpend}.`,
|
|
||||||
`Recent entries:\n${recent || "No entries logged yet."}`,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNewCoachChat(user: AuthUser): CoachChat {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
return {
|
|
||||||
id: makeId(),
|
|
||||||
userId: user.$id,
|
|
||||||
title: "new chat",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: "coach-welcome",
|
|
||||||
role: "assistant",
|
|
||||||
content: `hey ${firstName(user).toLocaleLowerCase()}, i can help with caffeine pace, sugar swaps, spend trends, and smarter quick-add choices.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleForChat(currentTitle: string, prompt: string) {
|
|
||||||
if (currentTitle !== "new chat") return currentTitle;
|
|
||||||
const cleaned = prompt.trim().replace(/\s+/g, " ").toLocaleLowerCase();
|
|
||||||
return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "new chat";
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstName(user: AuthUser) {
|
function firstName(user: AuthUser) {
|
||||||
const fallback = user.email?.split("@")[0] ?? "there";
|
const fallback = user.email?.split("@")[0] ?? "there";
|
||||||
const value = (user.name || fallback).trim();
|
const value = (user.name || fallback).trim();
|
||||||
@@ -3040,5 +2892,4 @@ function actionLabel(value: string) {
|
|||||||
.replace(/-/g, " ");
|
.replace(/-/g, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const TERMS_SUMMARY =
|
||||||
|
"By using track.9961.one you agree to use the tracker responsibly, keep your login secure, and accept that intake data is stored in your Appwrite account under your control. This applies only to track.9961.one.";
|
||||||
|
|
||||||
|
const PRIVACY_SUMMARY =
|
||||||
|
"track.9961.one stores intake logs and preferences in Appwrite, tied to your account. Coach chats can be encrypted client-side before upload. We do not sell your data. This policy applies only to track.9961.one.";
|
||||||
|
|
||||||
|
type LegalFootnoteProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LegalFootnote({ className = "" }: LegalFootnoteProps) {
|
||||||
|
return (
|
||||||
|
<footer className={`legal-footnote ${className}`.trim()} aria-label="Legal notices for track.9961.one">
|
||||||
|
<span className="legal-footnote-site">track.9961.one</span>
|
||||||
|
<span className="legal-footnote-links">
|
||||||
|
<span className="legal-tooltip-wrap">
|
||||||
|
<button className="legal-tooltip-trigger" type="button" aria-describedby="legal-terms-tip">
|
||||||
|
Terms
|
||||||
|
</button>
|
||||||
|
<span className="legal-tooltip" id="legal-terms-tip" role="tooltip">
|
||||||
|
{TERMS_SUMMARY}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="legal-tooltip-wrap">
|
||||||
|
<button className="legal-tooltip-trigger" type="button" aria-describedby="legal-privacy-tip">
|
||||||
|
Privacy
|
||||||
|
</button>
|
||||||
|
<span className="legal-tooltip" id="legal-privacy-tip" role="tooltip">
|
||||||
|
{PRIVACY_SUMMARY}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
+121
-14
@@ -85,10 +85,14 @@ textarea:focus-visible {
|
|||||||
.app-layout {
|
.app-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1580px;
|
max-width: 1680px;
|
||||||
gap: 18px;
|
gap: 16px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 8px 12px calc(108px + env(safe-area-inset-bottom, 0px));
|
padding:
|
||||||
|
max(12px, env(safe-area-inset-top, 0px))
|
||||||
|
max(12px, env(safe-area-inset-right, 0px))
|
||||||
|
calc(92px + env(safe-area-inset-bottom, 0px))
|
||||||
|
max(12px, env(safe-area-inset-left, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content,
|
.app-content,
|
||||||
@@ -96,6 +100,16 @@ textarea:focus-visible {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-view {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
max-width: 1050px;
|
max-width: 1050px;
|
||||||
margin: 10px auto 0;
|
margin: 10px auto 0;
|
||||||
@@ -296,29 +310,39 @@ textarea:focus-visible {
|
|||||||
.mobile-nav-bar {
|
.mobile-nav-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
bottom: 12px;
|
bottom: max(12px, env(safe-area-inset-bottom, 0px));
|
||||||
left: 12px;
|
left: 12px;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
|
|
||||||
background: rgba(255, 255, 255, 0.96);
|
background: rgba(255, 255, 255, 0.96);
|
||||||
border: 1px solid #d8e1ee;
|
border: 1px solid #d8e1ee;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
box-shadow: 0 12px 34px rgba(60, 64, 67, 0.16);
|
box-shadow: 0 12px 34px rgba(60, 64, 67, 0.16);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-item {
|
.mobile-nav-item {
|
||||||
|
display: flex;
|
||||||
min-height: 54px;
|
min-height: 54px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 16px;
|
||||||
color: #5f6670;
|
color: #5f6670;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-item:hover {
|
||||||
|
background: #f1f5fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-item-active {
|
.mobile-nav-item-active {
|
||||||
@@ -399,7 +423,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-icon-row {
|
.hero-icon-row {
|
||||||
display: flex;
|
display: none;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 26px;
|
gap: 26px;
|
||||||
@@ -1241,11 +1265,16 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) and (max-width: 1023px) {
|
||||||
.app-layout {
|
.app-layout {
|
||||||
padding: 14px 20px calc(112px + env(safe-area-inset-bottom, 0px));
|
padding:
|
||||||
|
max(14px, env(safe-area-inset-top, 0px))
|
||||||
|
20px
|
||||||
|
calc(96px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
.home-hero {
|
.home-hero {
|
||||||
padding: clamp(20px, 3vw, 40px) 16px 24px;
|
padding: clamp(20px, 3vw, 40px) 16px 24px;
|
||||||
}
|
}
|
||||||
@@ -1279,9 +1308,10 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.app-layout {
|
.app-layout {
|
||||||
grid-template-columns: 286px minmax(0, 1fr);
|
grid-template-columns: minmax(268px, 300px) minmax(0, 1fr);
|
||||||
gap: 30px;
|
gap: 32px;
|
||||||
padding: 18px 26px 26px;
|
align-items: start;
|
||||||
|
padding: 24px 28px 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-drawer {
|
.material-drawer {
|
||||||
@@ -1292,8 +1322,12 @@ textarea:focus-visible {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-view {
|
||||||
margin-top: 6px;
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-icon-row {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-app-bar--overview {
|
.top-app-bar--overview {
|
||||||
@@ -1409,6 +1443,79 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.legal-footnote {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--subtle, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-footnote-site {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-footnote-links {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-tooltip-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-tooltip-trigger {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary, #2563c7);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-tooltip-trigger:hover,
|
||||||
|
.legal-tooltip-trigger:focus-visible {
|
||||||
|
color: var(--primary-strong, #174ea6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
z-index: 40;
|
||||||
|
width: min(280px, 72vw);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(15, 23, 42, 0.96);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: 0 12px 28px rgba(2, 6, 23, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-tooltip-wrap:hover .legal-tooltip,
|
||||||
|
.legal-tooltip-wrap:focus-within .legal-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 380px) {
|
@media (max-width: 380px) {
|
||||||
.hero-icon-row {
|
.hero-icon-row {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const appwriteConfig = {
|
|||||||
projectId: env.VITE_APPWRITE_PROJECT_ID!,
|
projectId: env.VITE_APPWRITE_PROJECT_ID!,
|
||||||
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
||||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||||
|
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
|
||||||
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
|
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -122,3 +122,23 @@ export type LimitCheckResult = {
|
|||||||
todaySpend: number;
|
todaySpend: number;
|
||||||
pastStopTime: boolean;
|
pastStopTime: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatRole = "user" | "assistant";
|
||||||
|
|
||||||
|
export type CoachMessage = {
|
||||||
|
id: string;
|
||||||
|
role: ChatRole;
|
||||||
|
content: string;
|
||||||
|
thinking?: string;
|
||||||
|
pending?: boolean;
|
||||||
|
stopped?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CoachChat = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
messages: CoachMessage[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user