feat: add Home page with user dashboard and habit tracking features
- Implemented Home component with user authentication and loading states. - Created Welcome component for unauthenticated users with a sign-in option. - Developed Dashboard component to display user's habits and progress. - Added functionality for habit management, including adding, updating, and deleting habits. - Integrated HabitChart and HabitHistory components for visual representation of habits. - Introduced sharing functionality for progress via Discord integration. feat: establish Discord sharing configuration and routes - Added DiscordSharingConfig type and readDiscordSharingConfig function for environment variable management. - Created sharing contracts for input validation and data structure. - Implemented sharing routes for previewing and sending progress images to Discord. - Added tests for sharing routes to ensure authentication and proper error handling.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type Reac
|
||||
import { CalendarLegend } from "./CalendarLegend";
|
||||
import { Field } from "./Field";
|
||||
import { Button } from "./primitives";
|
||||
import { CalendarDays, Keyboard } from "lucide-react";
|
||||
import {
|
||||
describeDay,
|
||||
calendarTimeline,
|
||||
@@ -87,7 +88,7 @@ export function CalendarHeatmap({
|
||||
<div
|
||||
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
||||
data-months={months}
|
||||
style={{ "--calendar-weeks": timeline.weeks } as CSSProperties}
|
||||
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
|
||||
>
|
||||
<div className="ds-calendar-range-toolbar">
|
||||
<span className="ds-calendar-range-label" aria-live="polite">
|
||||
@@ -273,20 +274,29 @@ export function CalendarHeatmap({
|
||||
(compact ? "Demo history" : "Illustrative history")}
|
||||
</span>
|
||||
</span>
|
||||
<span id={instructionsId} className="ds-muted">
|
||||
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
|
||||
</span>
|
||||
<details className="ds-calendar-help">
|
||||
<summary><Keyboard size={16} aria-hidden="true" /> Keyboard shortcuts</summary>
|
||||
<p id={instructionsId}>
|
||||
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
|
||||
<span>
|
||||
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</span>
|
||||
<span>{describeDay(selected, unit)}</span>
|
||||
<div className="ds-date-inspector-heading">
|
||||
<CalendarDays size={22} aria-hidden="true" />
|
||||
<div>
|
||||
<span className="ds-eyebrow">SELECTED DAY</span>
|
||||
<time dateTime={selected.date}>
|
||||
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</time>
|
||||
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { useId, type CSSProperties, type ReactNode } from "react";
|
||||
import { ChevronDown, ListChecks } from "lucide-react";
|
||||
import { CalendarHeatmap } from "./CalendarHeatmap";
|
||||
import { demoCalendar } from "./calendar-model";
|
||||
|
||||
@@ -11,11 +12,15 @@ export function HabitChart({
|
||||
unit,
|
||||
color,
|
||||
children,
|
||||
headingActions,
|
||||
editor,
|
||||
tasks,
|
||||
tasksLabel = "Tasks for today",
|
||||
calendar,
|
||||
schedule = "Every day",
|
||||
due = true,
|
||||
id,
|
||||
headingLevel = 4,
|
||||
}: {
|
||||
name: string;
|
||||
method: string;
|
||||
@@ -24,27 +29,36 @@ export function HabitChart({
|
||||
unit: string;
|
||||
color: string;
|
||||
children?: ReactNode;
|
||||
headingActions?: ReactNode;
|
||||
editor?: ReactNode;
|
||||
tasks?: ReactNode;
|
||||
tasksLabel?: string;
|
||||
calendar?: ReactNode;
|
||||
schedule?: string;
|
||||
due?: boolean;
|
||||
id?: string;
|
||||
headingLevel?: 3 | 4;
|
||||
}) {
|
||||
const headingId = useId();
|
||||
const Heading = `h${headingLevel}` as const;
|
||||
return (
|
||||
<section className="ds-habit-chart" id={id} aria-labelledby={headingId}>
|
||||
<section className="ds-habit-chart" id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
|
||||
<header className="ds-habit-chart-heading">
|
||||
<div>
|
||||
<h4 id={headingId}>
|
||||
<span style={{ backgroundColor: color }} aria-hidden="true" />
|
||||
{name}
|
||||
</h4>
|
||||
<div className="ds-habit-title-row">
|
||||
<Heading id={headingId}>
|
||||
<span style={{ backgroundColor: color }} aria-hidden="true" />
|
||||
{name}
|
||||
</Heading>
|
||||
{headingActions}
|
||||
</div>
|
||||
<p>
|
||||
{method} · {schedule}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</header>
|
||||
{editor}
|
||||
<p className="ds-habit-chart-progress" aria-live="polite">
|
||||
<span>
|
||||
{due
|
||||
@@ -71,10 +85,13 @@ export function HabitChart({
|
||||
{tasks && (
|
||||
<details className="ds-task-accordion">
|
||||
<summary>
|
||||
Tasks for today{" "}
|
||||
<span>
|
||||
{value} / {target}
|
||||
<ListChecks size={20} aria-hidden="true" />
|
||||
<span className="ds-task-accordion-title">{tasksLabel}</span>
|
||||
<span className="ds-task-accordion-progress">
|
||||
<progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={Math.max(1, target)} />
|
||||
<span>{value} / {target}</span>
|
||||
</span>
|
||||
<ChevronDown className="ds-task-accordion-chevron" size={18} aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="ds-task-accordion-content">{tasks}</div>
|
||||
</details>
|
||||
|
||||
21
src/components/design-system/ItemActions.tsx
Normal file
21
src/components/design-system/ItemActions.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
|
||||
name: string;
|
||||
kind: "habit" | "task";
|
||||
disabled?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="ds-item-actions" role="group" aria-label={`${name} actions`}>
|
||||
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onEdit} aria-label={`Edit ${kind} ${name}`} title={`Edit ${kind}`}>
|
||||
<Pencil size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`Delete ${kind} ${name}`} title={`Delete ${kind}`}>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
50
src/components/design-system/PageLayout.tsx
Normal file
50
src/components/design-system/PageLayout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
/** The existing design-system page shell, shared without page-specific styling. */
|
||||
export function PageLayout({
|
||||
children,
|
||||
header,
|
||||
mainId = "main",
|
||||
wordmarkTo = "/",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
header?: ReactNode;
|
||||
mainId?: string;
|
||||
wordmarkTo?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="ds-root" id="top">
|
||||
<a
|
||||
className="ds-skip-link"
|
||||
href={`#${mainId}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
document.getElementById(mainId)?.focus();
|
||||
}}
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<header className="ds-header">
|
||||
<Link className="ds-wordmark" to={wordmarkTo}>
|
||||
minabot<span aria-hidden="true">.</span>
|
||||
</Link>
|
||||
{header}
|
||||
</header>
|
||||
<main id={mainId} className="ds-main" tabIndex={-1}>
|
||||
{children}
|
||||
<footer className="ds-footer">
|
||||
<span className="ds-wordmark">minabot.</span>
|
||||
<span>A little, every day.</span>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}
|
||||
>
|
||||
Back to top ↑
|
||||
</Button>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/design-system/WelcomePanel.tsx
Normal file
73
src/components/design-system/WelcomePanel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Globe2 } from "lucide-react";
|
||||
import { formatTrackingDate } from "../../lib/dashboard";
|
||||
|
||||
export function WelcomePanel({
|
||||
name,
|
||||
avatarUrl,
|
||||
date,
|
||||
timezone,
|
||||
children,
|
||||
}: {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
date?: string;
|
||||
timezone: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [failedAvatar, setFailedAvatar] = useState<string | null>(null);
|
||||
// The API date already belongs to the account's timezone. Keep its date parts
|
||||
// intact instead of interpreting them in the browser's timezone.
|
||||
const day = date ? new Date(`${date}T12:00:00Z`) : null;
|
||||
|
||||
return (
|
||||
<section className="ds-welcome-panel" aria-labelledby="dashboard-title">
|
||||
<div className="ds-welcome-content">
|
||||
<div className="ds-welcome-identity">
|
||||
<div className="ds-avatar">
|
||||
{avatarUrl && failedAvatar !== avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${name}’s Discord avatar`}
|
||||
width={64}
|
||||
height={64}
|
||||
onError={() => setFailedAvatar(avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<span aria-label={`${name}’s avatar`} role="img">
|
||||
{Array.from(name.trim())[0]?.toUpperCase() || "?"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="ds-eyebrow">YOUR DAILY CHECK-IN</p>
|
||||
</div>
|
||||
<h1 id="dashboard-title" className="type-display">
|
||||
Welcome back, <em>{name}.</em>
|
||||
</h1>
|
||||
<p className="ds-welcome-subtitle">Make time for today.</p>
|
||||
{children}
|
||||
</div>
|
||||
<div className="ds-welcome-calendar">
|
||||
{day && date ? (
|
||||
<time className="ds-date-sheet" dateTime={date} aria-label={formatTrackingDate(date)}>
|
||||
<span className="ds-date-month">
|
||||
{day.toLocaleDateString("en", { month: "long", timeZone: "UTC" })}
|
||||
<span className="ds-muted">{day.getUTCFullYear()}</span>
|
||||
</span>
|
||||
<span className="ds-date-number type-display">{day.getUTCDate()}</span>
|
||||
<span className="ds-date-weekday">
|
||||
{day.toLocaleDateString("en", { weekday: "long", timeZone: "UTC" })}
|
||||
</span>
|
||||
<span className="ds-date-caption ds-eyebrow">TODAY, AT YOUR OWN PACE</span>
|
||||
</time>
|
||||
) : (
|
||||
<p className="ds-muted" role="status">Your day is loading…</p>
|
||||
)}
|
||||
<p className="ds-welcome-timezone">
|
||||
<Globe2 size={16} aria-hidden="true" />
|
||||
<span>{timezone}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
AnchorHTMLAttributes,
|
||||
@@ -46,7 +47,7 @@ export function SectionHeading({
|
||||
return (
|
||||
<header className="ds-section-heading">
|
||||
<span className="ds-eyebrow">{number}</span>
|
||||
<h2 id={id}>{title}</h2>
|
||||
<h2 id={id} tabIndex={-1}>{title}</h2>
|
||||
{children && <p>{children}</p>}
|
||||
</header>
|
||||
);
|
||||
@@ -54,13 +55,23 @@ export function SectionHeading({
|
||||
|
||||
export function Checkbox({
|
||||
label,
|
||||
description,
|
||||
className = "",
|
||||
...props
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string }) {
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; description?: string }) {
|
||||
const descriptionId = useId();
|
||||
return (
|
||||
<label className={`ds-checkbox ${className}`}>
|
||||
<input type="checkbox" {...props} />
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={description ? label : undefined}
|
||||
{...props}
|
||||
aria-describedby={[props["aria-describedby"], description ? descriptionId : undefined].filter(Boolean).join(" ") || undefined}
|
||||
/>
|
||||
<span className="ds-checkbox-copy">
|
||||
<span className="ds-checkbox-label">{label}</span>
|
||||
{description && <span id={descriptionId} className="ds-checkbox-description">{description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user