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:
syntaxbullet
2026-09-04 17:48:54 +02:00
parent dde5e77317
commit ecc2620a4c
34 changed files with 4017 additions and 140 deletions

View File

@@ -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>
);

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}