feat(web): snooze suggestion card on Dashboard, event countdown large display
- Dashboard: adaptive snooze suggestion cards (dismissable, max 2 shown, pattern-based) - TimerCard: event countdown type gets large days display with milestone progress bar - Progress bar color-codes: accent → warning (70%) → danger (90%) - Shows days + hours remaining with target date - Updated roadmap: marked snooze suggestion card and event visual as completed - 373 tests across 16 files, tsc clean
This commit is contained in:
parent
f61483e7a5
commit
d909830fcd
@ -381,7 +381,7 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
|
||||
- [x] After 5+ data points: "You always snooze your morning alarm 3 times. Set it 15 minutes later?"
|
||||
- [x] Label normalization for pattern grouping (e.g. "Meeting with Bob" → "meeting with")
|
||||
- [x] checkForSnoozeSuggestion() for real-time suggestions on timer creation
|
||||
- [ ] Suggestion shown as dismissable card on dashboard
|
||||
- [x] Suggestion shown as dismissable card on dashboard
|
||||
- [ ] Accept → auto-adjusts recurring timer
|
||||
- [x] Unit tests (22 tests)
|
||||
|
||||
@ -390,7 +390,7 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
|
||||
- [x] Timer type `event` in data model + createEvent factory + store addEvent
|
||||
- [x] Event tab in CreateTimerModal with date picker + days-remaining preview
|
||||
- [x] Use cases: "132 days until wedding", "47 days until vacation"
|
||||
- [ ] Visual: large countdown display with milestone progress bar
|
||||
- [x] Visual: large days display with milestone progress bar (color-coded: accent → warning → danger)
|
||||
|
||||
- [x] **Timer export / import (local backup) — `lib/export.ts` + History page**
|
||||
- [x] "Export timers" → JSON download of all timers
|
||||
|
||||
@ -17,12 +17,16 @@ import Link from 'next/link';
|
||||
import { FeedbackButton } from './FeedbackButton';
|
||||
import { InstallPrompt } from './InstallPrompt';
|
||||
import { useTheme } from '@/lib/use-theme';
|
||||
import { getSnoozeSuggestions } from '@/lib/adaptive-snooze';
|
||||
import type { SnoozeSuggestion } from '@/lib/adaptive-snooze';
|
||||
|
||||
export function Dashboard() {
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [filterCategory, setFilterCategory] = useState<string | null>(null);
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<string>>(new Set());
|
||||
const [snoozeSuggestions, setSnoozeSuggestions] = useState<SnoozeSuggestion[]>([]);
|
||||
const timers = useTimerStore((s) => s.timers);
|
||||
const now = useTimerStore((s) => s.now);
|
||||
const { pause, resume } = useTimerStore();
|
||||
@ -56,6 +60,7 @@ export function Dashboard() {
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
requestNotificationPermission();
|
||||
setSnoozeSuggestions(getSnoozeSuggestions());
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
@ -267,6 +272,41 @@ export function Dashboard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Snooze suggestions */}
|
||||
{snoozeSuggestions.filter((s) => !dismissedSuggestions.has(s.labelPattern)).length > 0 && (
|
||||
<div className="mb-4 space-y-2">
|
||||
{snoozeSuggestions
|
||||
.filter((s) => !dismissedSuggestions.has(s.labelPattern))
|
||||
.slice(0, 2)
|
||||
.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.labelPattern}
|
||||
className="rounded-xl border p-3 flex items-center justify-between"
|
||||
style={{
|
||||
backgroundColor: 'rgba(90,140,255,0.08)',
|
||||
borderColor: 'rgba(90,140,255,0.2)',
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate" style={{ color: 'var(--cm-accent)' }}>
|
||||
Snooze pattern detected
|
||||
</p>
|
||||
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||
{suggestion.message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissedSuggestions((prev) => new Set([...prev, suggestion.labelPattern]))}
|
||||
className="ml-3 text-xs px-2 py-1 rounded-lg cursor-pointer shrink-0"
|
||||
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-tertiary)' }}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active timers */}
|
||||
{activeTimers.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
|
||||
@ -130,7 +130,52 @@ export function TimerCard({ timer }: TimerCardProps) {
|
||||
</h3>
|
||||
|
||||
{/* Countdown / Time */}
|
||||
{!isDone && (
|
||||
{!isDone && timer.type === 'event' && (
|
||||
<div className="mb-3">
|
||||
{/* Large days display for event countdowns */}
|
||||
{(() => {
|
||||
const days = Math.floor(remaining / 86_400_000);
|
||||
const hours = Math.floor((remaining % 86_400_000) / 3_600_000);
|
||||
const totalDays = timer.duration ? Math.ceil(timer.duration / 86_400_000) : days;
|
||||
const progress = totalDays > 0 ? Math.max(0, Math.min(1, 1 - days / totalDays)) : 0;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
className="text-4xl font-mono font-bold tabular-nums tracking-tight"
|
||||
style={{ color: days <= 1 ? urgencyConfig.color : 'var(--cm-accent)' }}
|
||||
>
|
||||
{days}
|
||||
</span>
|
||||
<span className="text-lg font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||
day{days !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
{hours}h remaining
|
||||
</span>
|
||||
</div>
|
||||
{/* Milestone progress bar */}
|
||||
<div className="mt-2 h-2 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--cm-surface-muted)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor: progress > 0.9 ? 'var(--cm-danger)' : progress > 0.7 ? 'var(--cm-warning)' : 'var(--cm-accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>Created</span>
|
||||
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
{new Date(timer.targetTime).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{!isDone && timer.type !== 'event' && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
|
||||
Loading…
Reference in New Issue
Block a user