ui update and some backend functionality adding in accordance with research on adhd and ux design

This commit is contained in:
2026-02-14 17:21:37 -06:00
parent 4d3a9fbd54
commit fb480eacb2
32 changed files with 9549 additions and 248 deletions

View File

@@ -0,0 +1,104 @@
'use client';
import { useEffect, useState } from 'react';
import api from '@/lib/api';
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export default function PushNotificationToggle() {
const [supported, setSupported] = useState(false);
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const check = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
setLoading(false);
return;
}
setSupported(true);
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
setEnabled(!!sub);
} catch {
// ignore
}
setLoading(false);
};
check();
}, []);
const toggle = async () => {
if (loading) return;
setLoading(true);
try {
const reg = await navigator.serviceWorker.ready;
if (enabled) {
// Unsubscribe
const sub = await reg.pushManager.getSubscription();
if (sub) {
await api.notifications.unsubscribe(sub.endpoint);
await sub.unsubscribe();
}
setEnabled(false);
} else {
// Subscribe
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
setLoading(false);
return;
}
const { public_key } = await api.notifications.getVapidPublicKey();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key).buffer as ArrayBuffer,
});
const subJson = sub.toJSON();
await api.notifications.subscribe(subJson);
setEnabled(true);
}
} catch (err) {
console.error('Push notification toggle failed:', err);
}
setLoading(false);
};
if (!supported) return null;
return (
<div className="flex items-center justify-between bg-white rounded-xl p-4 shadow-sm">
<div>
<h3 className="font-semibold text-gray-900 text-sm">Push Notifications</h3>
<p className="text-xs text-gray-500">Get reminders on this device</p>
</div>
<button
onClick={toggle}
disabled={loading}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-indigo-600' : 'bg-gray-300'
} ${loading ? 'opacity-50' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { CheckIcon } from '@/components/ui/Icons';
interface Step {
id: string;
name: string;
duration_minutes?: number;
}
interface VisualTimelineProps {
steps: Step[];
currentStepIndex: number;
completedSteps: Set<number>;
}
export default function VisualTimeline({ steps, currentStepIndex, completedSteps }: VisualTimelineProps) {
const [expanded, setExpanded] = useState(false);
if (steps.length === 0) return null;
return (
<div
className="px-4 py-2 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
// Expanded: show step names
<div className="flex flex-col gap-1">
{steps.map((step, i) => {
const isCompleted = completedSteps.has(i);
const isCurrent = i === currentStepIndex;
const isUpcoming = !isCompleted && !isCurrent;
return (
<div key={step.id} className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 transition-all ${
isCompleted
? 'bg-indigo-500'
: isCurrent
? 'bg-indigo-400 ring-2 ring-indigo-400/50 ring-offset-2 ring-offset-gray-950'
: 'bg-gray-700'
}`}>
{isCompleted ? (
<CheckIcon size={14} className="text-white" />
) : (
<span className={`text-xs font-medium ${isCurrent ? 'text-white' : 'text-gray-400'}`}>
{i + 1}
</span>
)}
</div>
<span className={`text-sm truncate ${
isCompleted ? 'text-white/40 line-through' :
isCurrent ? 'text-white font-medium' :
'text-white/50'
}`}>
{step.name}
</span>
{step.duration_minutes && (
<span className="text-xs text-white/30 ml-auto shrink-0">
{step.duration_minutes}m
</span>
)}
</div>
);
})}
<p className="text-white/30 text-xs text-center mt-1">Tap to collapse</p>
</div>
) : (
// Collapsed: just circles
<div className="flex items-center justify-center gap-1.5">
{steps.map((step, i) => {
const isCompleted = completedSteps.has(i);
const isCurrent = i === currentStepIndex;
return (
<div
key={step.id}
className={`rounded-full transition-all ${
isCompleted
? 'w-3 h-3 bg-indigo-500'
: isCurrent
? 'w-4 h-4 bg-indigo-400 animate-gentle-pulse'
: 'w-3 h-3 bg-gray-700'
}`}
/>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
interface AnimatedCheckmarkProps {
size?: number;
color?: string;
strokeWidth?: number;
delay?: number;
}
export default function AnimatedCheckmark({
size = 48,
color = '#22c55e',
strokeWidth = 3,
delay = 0,
}: AnimatedCheckmarkProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
style={{ animationDelay: `${delay}ms` }}
>
<circle
cx="12"
cy="12"
r="10"
stroke={color}
strokeWidth={strokeWidth}
opacity={0.2}
/>
<path
d="M7 12.5l3.5 3.5 6.5-7"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="24"
strokeDashoffset="24"
className="animate-checkmark-draw"
style={{ animationDelay: `${delay}ms` }}
/>
</svg>
);
}

View File

@@ -708,6 +708,135 @@ export function TimerIcon({ className = '', size = 24 }: IconProps) {
);
}
export function LockIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
export function SparklesIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
<path d="M5 3v4" />
<path d="M19 17v4" />
<path d="M3 5h4" />
<path d="M17 19h4" />
</svg>
);
}
export function TrophyIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
);
}
export function MapPinIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
}
export function VolumeIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
);
}
export function VolumeOffIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="22" y1="9" x2="16" y2="15" />
<line x1="16" y1="9" x2="22" y2="15" />
</svg>
);
}
export function SkipForwardIcon({ className = '', size = 24 }: IconProps) {
return (
<svg