changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View File

@@ -6,7 +6,6 @@ import {
AuthTokenResponse,
BulkActionResponse,
CalendarDayResponse,
CorrelationResponse,
EntryResponse,
ImportResultResponse,
MediaIdResponse,
@@ -14,20 +13,35 @@ import {
ReminderResponse,
UserResponse,
VapidKeyResponse,
type ChangePasswordRequest,
type CreateActivityRequest,
type CreateEntryRequest,
type CreateReminderRequest,
type DateRangeParams,
type ListEntriesParams,
type LoginRequest,
type RegisterRequest,
type RenameActivityRequest,
type ReplaceActivityRequest,
type SetCategoryRequest,
type UpdateEntryRequest,
type UpdateProfileRequest,
type UpdateReminderRequest,
ProviderConnectionResponse,
DailyMetricResponse,
RejectionResponse,
RestoreOutcomeResponse,
CycleViewResponse,
PreferencesResponse,
ApiTokenResponse,
MintedApiTokenResponse,
CorrelationRowResponse,
DimensionPayload,
} from "./schema"
import type {
ChangePasswordRequest,
CreateActivityRequest,
CreateEntryRequest,
DateSpanParams,
MetricPayload,
SubsonicCredential,
CreateReminderRequest,
DateRangeParams,
ListEntriesParams,
LoginRequest,
RegisterRequest,
RenameActivityRequest,
ReplaceActivityRequest,
SetCategoryRequest,
UpdateEntryRequest,
UpdateProfileRequest,
UpdateReminderRequest,
} from "./schema"
const api = axios.create({
@@ -124,7 +138,7 @@ async function request<T>(
}
async function requestVoid(
method: "post" | "patch" | "delete",
method: "post" | "put" | "patch" | "delete",
path: string,
data?: unknown
): Promise<void> {
@@ -168,6 +182,87 @@ export const auth = {
requestVoid("post", "/api/v1/auth/logout", { refreshToken }),
}
// ── Providers ───────────────────────────────────────────────────
export const providers = {
list: () =>
request("get", "/api/v1/providers", ProviderConnectionResponse.array()),
connect: (provider: string, credential: SubsonicCredential) =>
requestVoid("put", `/api/v1/providers/${provider}`, { credential }),
disconnect: (provider: string) =>
requestVoid("delete", `/api/v1/providers/${provider}`),
nowPlaying: () =>
request(
"get",
"/api/v1/providers/now-playing",
DimensionPayload.nullable()
),
}
// ── Cycle ───────────────────────────────────────────────────────
export const cycle = {
read: () => request("get", "/api/v1/cycle", CycleViewResponse),
record: (date: string) => requestVoid("put", `/api/v1/cycle/${date}`),
forget: (date: string) => requestVoid("delete", `/api/v1/cycle/${date}`),
preferences: () =>
request("get", "/api/v1/users/me/preferences", PreferencesResponse),
setTracking: (tracksCycle: boolean) =>
request("patch", "/api/v1/users/me/preferences", PreferencesResponse, {
tracksCycle,
}),
}
// ── API tokens ──────────────────────────────────────────────────
export const tokens = {
list: () => request("get", "/api/v1/tokens", ApiTokenResponse.array()),
mint: (name: string) =>
request("post", "/api/v1/tokens", MintedApiTokenResponse, { name }),
revoke: (id: string) => requestVoid("delete", `/api/v1/tokens/${id}`),
}
// ── Correlations ────────────────────────────────────────────────
export const correlations = {
list: (params: DateSpanParams) =>
request(
"get",
"/api/v1/correlations",
CorrelationRowResponse.array(),
undefined,
params
),
}
// ── Daily metrics ───────────────────────────────────────────────
export const metrics = {
list: (params: DateSpanParams) =>
request(
"get",
"/api/v1/metrics",
DailyMetricResponse.array(),
undefined,
params
),
set: (date: string, values: MetricPayload[]) =>
requestVoid("put", `/api/v1/metrics/${date}`, { metrics: values }),
rejections: () =>
request("get", "/api/v1/metrics/rejections", RejectionResponse.array()),
}
// ── Entries ─────────────────────────────────────────────────────
export const entries = {
@@ -232,15 +327,6 @@ export const entries = {
`/api/v1/entries/filter/activity/${activityId}`,
EntryResponse.array()
),
correlation: (activityId: string, params?: { from?: string; to?: string }) =>
request(
"get",
`/api/v1/entries/correlation/${activityId}`,
CorrelationResponse,
undefined,
params
),
}
// ── Activities ──────────────────────────────────────────────────
@@ -325,19 +411,26 @@ export const media = {
// ── Data ────────────────────────────────────────────────────────
export const data = {
export: async (): Promise<Blob> => {
try {
const res = await api.get("/api/v1/data/export", { responseType: "blob" })
return res.data as Blob
} catch (err) {
return handleError(err)
}
},
completeBackup: () => download("/api/v1/data/backup"),
shareableExtract: () => download("/api/v1/data/extract"),
restore: (file: File) =>
uploadForm("/api/v1/data/restore", file, RestoreOutcomeResponse),
import: (file: File) =>
uploadForm("/api/v1/data/import", file, ImportResultResponse),
}
async function download(path: string): Promise<Blob> {
try {
const res = await api.get(path, { responseType: "blob" })
return res.data as Blob
} catch (err) {
return handleError(err)
}
}
// ── Push ───────────────────────────────────────────────────────
export const push = {

View File

@@ -23,17 +23,48 @@ export const ActivityResponse = z.object({
})
export type ActivityResponse = z.infer<typeof ActivityResponse>
export const DimensionPayload = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("content"), text: z.string() }),
z.object({ kind: z.literal("activities"), ids: z.array(uuid) }),
z.object({ kind: z.literal("photos"), ids: z.array(uuid) }),
z.object({ kind: z.literal("voiceMemos"), ids: z.array(uuid) }),
z.object({
kind: z.literal("location"),
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
}),
z.object({
kind: z.literal("song"),
title: z.string(),
artist: z.string(),
album: z.string().optional(),
recordingId: uuid.optional(),
}),
z.object({
kind: z.literal("weather"),
condition: z.enum([
"clear",
"cloudy",
"fog",
"drizzle",
"rain",
"snow",
"thunderstorm",
]),
temperature: z.number(),
observedBy: z.string(),
}),
])
export type DimensionPayload = z.infer<typeof DimensionPayload>
export const EntryResponse = z.object({
id: uuid,
userId: uuid,
mood: Mood,
moodLabel: z.string(),
loggedAt: datetime,
activities: z.array(uuid),
content: z.string().nullable(),
photos: z.array(uuid),
dimensions: z.array(DimensionPayload),
photoUrls: z.array(z.string()),
voiceMemos: z.array(uuid),
voiceMemoUrls: z.array(z.string()),
createdAt: datetime,
updatedAt: datetime,
@@ -43,8 +74,10 @@ export type EntryResponse = z.infer<typeof EntryResponse>
export const CalendarDayResponse = z.object({
date: date,
entries: z.array(EntryResponse),
dominantMood: Mood.nullable(),
dominantMoodLabel: z.string().nullable(),
dayMood: z.number().nullable(),
mood: Mood.nullable(),
moodLabel: z.string().nullable(),
cycleDay: z.int().nullable(),
})
export type CalendarDayResponse = z.infer<typeof CalendarDayResponse>
@@ -98,12 +131,6 @@ export const BulkActionResponse = z.object({
})
export type BulkActionResponse = z.infer<typeof BulkActionResponse>
export const CorrelationResponse = z.object({
activityId: uuid,
correlation: z.number().nullable(),
})
export type CorrelationResponse = z.infer<typeof CorrelationResponse>
export const ImportResultResponse = z.object({
imported: z.number(),
skipped: z.number(),
@@ -155,20 +182,14 @@ export type RegisterRequest = z.infer<typeof RegisterRequest>
export const CreateEntryRequest = z.object({
mood: Mood,
loggedAt: z.string().optional(),
activityIds: z.array(z.string()).optional(),
content: z.string().optional(),
photoIds: z.array(z.string()).optional(),
voiceMemoIds: z.array(z.string()).optional(),
dimensions: z.array(DimensionPayload).optional(),
})
export type CreateEntryRequest = z.infer<typeof CreateEntryRequest>
export const UpdateEntryRequest = z.object({
mood: Mood,
loggedAt: z.string().optional(),
activityIds: z.array(z.string()).optional(),
content: z.string().optional(),
photoIds: z.array(z.string()).optional(),
voiceMemoIds: z.array(z.string()).optional(),
dimensions: z.array(DimensionPayload).optional(),
})
export type UpdateEntryRequest = z.infer<typeof UpdateEntryRequest>
@@ -238,3 +259,149 @@ export const DateRangeParams = z.object({
to: z.string(),
})
export type DateRangeParams = z.infer<typeof DateRangeParams>
export const ProviderConnectionResponse = z.object({
provider: z.string(),
connectedAt: datetime,
})
export type ProviderConnectionResponse = z.infer<
typeof ProviderConnectionResponse
>
export const MetricKind = z.enum([
"steps",
"sleepMinutes",
"awakeMinutes",
"restingHeartRate",
"hrv",
"exerciseMinutes",
"screenTimeMinutes",
"alcoholicDrinks",
])
export type MetricKind = z.infer<typeof MetricKind>
export const DailyMetricResponse = z.object({
date: date,
kind: MetricKind,
value: z.int(),
provider: z.string().optional(),
})
export type DailyMetricResponse = z.infer<typeof DailyMetricResponse>
export const MetricPayload = z.object({
kind: MetricKind,
value: z.int().nullable(),
})
export type MetricPayload = z.infer<typeof MetricPayload>
export const SetDailyMetricsRequest = z.object({
metrics: z.array(MetricPayload),
})
export type SetDailyMetricsRequest = z.infer<typeof SetDailyMetricsRequest>
export const DateSpanParams = z.object({
from: date,
to: date,
})
export type DateSpanParams = z.infer<typeof DateSpanParams>
export const CorrelationInputPayload = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("metric"), metric: MetricKind }),
z.object({ kind: z.literal("moonPhase") }),
z.object({
kind: z.literal("activity"),
activityId: uuid,
name: z.string(),
}),
])
export type CorrelationInputPayload = z.infer<typeof CorrelationInputPayload>
export const StrategyScoreResponse = z.object({
strategy: z.string(),
coefficient: z.number(),
heldUp: z.boolean(),
})
export type StrategyScoreResponse = z.infer<typeof StrategyScoreResponse>
export const AgreementResponse = z.object({
agreeing: z.int(),
applicable: z.int(),
})
export type AgreementResponse = z.infer<typeof AgreementResponse>
export const CorrelationRowResponse = z.object({
input: CorrelationInputPayload,
sampleSize: z.int(),
agreement: AgreementResponse,
scores: z.array(StrategyScoreResponse),
})
export type CorrelationRowResponse = z.infer<typeof CorrelationRowResponse>
export const ApiTokenResponse = z.object({
id: uuid,
name: z.string(),
scope: z.string(),
createdAt: datetime,
lastUsedAt: datetime.nullable(),
})
export type ApiTokenResponse = z.infer<typeof ApiTokenResponse>
export const MintedApiTokenResponse = z.object({
token: ApiTokenResponse,
secret: z.string(),
})
export type MintedApiTokenResponse = z.infer<typeof MintedApiTokenResponse>
export const MintApiTokenRequest = z.object({
name: z.string(),
})
export type MintApiTokenRequest = z.infer<typeof MintApiTokenRequest>
export const RejectionResponse = z.object({
id: uuid,
origin: z.enum(["import", "storedRow"]),
provider: z.string().nullable(),
date: date.nullable(),
kind: z.string(),
value: z.int().nullable(),
reason: z.string(),
recordedAt: datetime,
})
export type RejectionResponse = z.infer<typeof RejectionResponse>
export const PreferencesResponse = z.object({
tracksCycle: z.boolean(),
})
export type PreferencesResponse = z.infer<typeof PreferencesResponse>
export const CyclePositionResponse = z.object({
day: z.int(),
progress: z.number(),
})
export type CyclePositionResponse = z.infer<typeof CyclePositionResponse>
export const CycleViewResponse = z.object({
tracking: z.boolean(),
starts: z.array(date),
today: CyclePositionResponse.nullable(),
usualLength: z.int(),
})
export type CycleViewResponse = z.infer<typeof CycleViewResponse>
export const RestoreOutcomeResponse = z.object({
entries: z.int(),
metrics: z.int(),
cycleStarts: z.int(),
activities: z.int(),
reminders: z.int(),
media: z.int(),
unreadable: z.array(z.string()),
})
export type RestoreOutcomeResponse = z.infer<typeof RestoreOutcomeResponse>
export const SubsonicCredential = z.object({
url: z.string(),
username: z.string(),
password: z.string(),
})
export type SubsonicCredential = z.infer<typeof SubsonicCredential>

View File

@@ -1,3 +1,4 @@
import { contentOf } from "@/lib/dimensions"
import { format } from "date-fns"
import { Link } from "@tanstack/react-router"
import { Card, CardContent } from "@/components/ui/card"
@@ -23,9 +24,9 @@ export function EntryCard({ entry }: EntryCardProps) {
{format(date, "h:mm a")}
</span>
</div>
{entry.content && (
{contentOf(entry) && (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{entry.content}
{contentOf(entry)}
</p>
)}
</div>

View File

@@ -2,23 +2,27 @@ import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Textarea } from "@/components/ui/textarea"
import { Input } from "@/components/ui/input"
import { MoodPicker } from "@/components/mood/mood-picker"
import { LoggedAtPicker } from "@/components/entry/logged-at-picker"
import { ActivityPicker } from "@/components/activity/activity-picker"
import { PhotoPicker } from "@/components/media/photo-picker"
import { VoiceMemoPicker } from "@/components/media/voice-memo-picker"
import { useActivities } from "@/hooks/use-activities"
import { useNowPlaying } from "@/hooks/use-providers"
import { media as mediaApi } from "@/api/client"
import { toast } from "@/components/ui/toast"
import type { MoodValue } from "@/lib/mood"
import type { MediaItem } from "@/lib/media"
import { getExistingIds, getPendingFiles } from "@/lib/media"
import { buildDimensions } from "@/lib/dimensions"
import { fromLoggedAt, toLoggedAt } from "@/lib/logged-at"
import type { DimensionPayload } from "@/api/schema"
export interface EntryFormData {
mood: number
content?: string
activityIds?: string[]
photoIds?: string[]
voiceMemoIds?: string[]
loggedAt: string
dimensions?: DimensionPayload[]
}
interface EntryFormProps {
@@ -28,10 +32,13 @@ interface EntryFormProps {
onSubmit: (data: EntryFormData) => void
initial?: {
mood?: number
loggedAt?: string
content?: string
activities?: string[]
photos?: string[]
voiceMemos?: string[]
location?: { latitude: number; longitude: number } | null
song?: { title: string; artist: string } | null
}
}
@@ -45,6 +52,9 @@ export function EntryForm({
const [mood, setMood] = useState<MoodValue | undefined>(
initial?.mood as MoodValue | undefined
)
const [loggedAt, setLoggedAt] = useState<Date>(
initial?.loggedAt ? fromLoggedAt(initial.loggedAt) : new Date()
)
const [content, setContent] = useState(initial?.content ?? "")
const [selectedActivities, setSelectedActivities] = useState<string[]>(
initial?.activities ?? []
@@ -55,7 +65,57 @@ export function EntryForm({
const [voiceMemoItems, setVoiceMemoItems] = useState<MediaItem[]>(
(initial?.voiceMemos ?? []).map((id) => ({ type: "existing" as const, id }))
)
const [songTitle, setSongTitle] = useState(initial?.song?.title ?? "")
const [songArtist, setSongArtist] = useState(initial?.song?.artist ?? "")
const [location, setLocation] = useState<{
latitude: number
longitude: number
} | null>(initial?.location ?? null)
const [locating, setLocating] = useState(false)
const nowPlaying = useNowPlaying()
const fillFromNowPlaying = () => {
nowPlaying.mutate(undefined, {
onSuccess: (playing) => {
if (playing?.kind !== "song") {
toast.add({ title: "Nothing is playing right now", type: "info" })
return
}
setSongTitle(playing.title)
setSongArtist(playing.artist)
},
onError: () =>
toast.add({
title: "Could not reach your music server",
type: "error",
}),
})
}
const [uploading, setUploading] = useState(false)
const captureLocation = () => {
if (!("geolocation" in navigator)) {
toast.add({
title: "Location is unavailable on this device",
type: "error",
})
return
}
setLocating(true)
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
})
setLocating(false)
},
() => {
toast.add({ title: "Could not read your location", type: "error" })
setLocating(false)
}
)
}
const { data: activities } = useActivities()
const toggleActivity = (id: string) => {
@@ -83,10 +143,15 @@ export function EntryForm({
onSubmit({
mood,
content: content.trim() || undefined,
activityIds: selectedActivities.length ? selectedActivities : undefined,
photoIds: photoIds.length ? photoIds : undefined,
voiceMemoIds: voiceMemoIds.length ? voiceMemoIds : undefined,
loggedAt: toLoggedAt(loggedAt),
dimensions: buildDimensions({
content,
activityIds: selectedActivities,
photoIds,
voiceMemoIds,
location,
song: { title: songTitle, artist: songArtist },
}),
})
} catch {
toast.add({ title: "Failed to upload media", type: "error" })
@@ -103,6 +168,15 @@ export function EntryForm({
<MoodPicker value={mood} onChange={setMood} size="lg" />
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Logged</CardTitle>
</CardHeader>
<CardContent>
<LoggedAtPicker value={loggedAt} onChange={setLoggedAt} />
</CardContent>
</Card>
{activities?.length ? (
<Card>
<CardHeader className="pb-2">
@@ -132,6 +206,69 @@ export function EntryForm({
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Song</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Input
placeholder="Title"
value={songTitle}
onChange={(e) => setSongTitle(e.target.value)}
/>
<Input
placeholder="Artist"
value={songArtist}
onChange={(e) => setSongArtist(e.target.value)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={fillFromNowPlaying}
disabled={nowPlaying.isPending}
>
{nowPlaying.isPending ? "Checking…" : "What's playing?"}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Location</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={captureLocation}
disabled={locating}
>
{locating
? "Locating…"
: location
? "Update location"
: "Add location"}
</Button>
{location && (
<span className="text-xs text-muted-foreground">
{location.latitude.toFixed(4)}, {location.longitude.toFixed(4)}
</span>
)}
{location && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setLocation(null)}
>
Remove
</Button>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Photos</CardTitle>

View File

@@ -13,12 +13,12 @@ export function EntryList({ entries, limit }: EntryListProps) {
return (
<div className="flex flex-col gap-4">
{grouped.map(({ label, entries }) => (
{grouped.map(({ label, entries: onThatDay }) => (
<div key={label} className="flex flex-col gap-2">
<h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{label}
</h3>
{entries.map((entry) => (
{onThatDay.map((entry) => (
<EntryCard key={entry.id} entry={entry} />
))}
</div>
@@ -42,9 +42,9 @@ function groupByDay(
groups.get(key)!.push(entry)
}
return Array.from(groups.entries()).map(([key, entries]) => ({
return Array.from(groups.entries()).map(([key, onThatDay]) => ({
label: formatDayLabel(new Date(key)),
entries,
entries: onThatDay,
}))
}

View File

@@ -0,0 +1,41 @@
import {
Cloud,
CloudDrizzle,
CloudFog,
CloudLightning,
CloudRain,
Snowflake,
Sun,
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { weatherOf } from "@/lib/dimensions"
import type { EntryResponse } from "@/api/schema"
type Condition = NonNullable<ReturnType<typeof weatherOf>>["condition"]
const LOOKS_LIKE: Record<Condition, { icon: LucideIcon; label: string }> = {
clear: { icon: Sun, label: "Clear" },
cloudy: { icon: Cloud, label: "Cloudy" },
fog: { icon: CloudFog, label: "Fog" },
drizzle: { icon: CloudDrizzle, label: "Drizzle" },
rain: { icon: CloudRain, label: "Rain" },
snow: { icon: Snowflake, label: "Snow" },
thunderstorm: { icon: CloudLightning, label: "Thunderstorm" },
}
export function EntryWeather({ entry }: { entry: EntryResponse }) {
const weather = weatherOf(entry)
if (!weather) return null
const { icon: Icon, label } = LOOKS_LIKE[weather.condition]
return (
<span
className="flex items-center gap-1.5 text-xs text-muted-foreground"
title={`Observed by ${weather.observedBy}`}
>
<Icon className="h-3.5 w-3.5" />
{label}, {Math.round(weather.temperature)}°C
</span>
)
}

View File

@@ -0,0 +1,63 @@
import { useState } from "react"
import { format } from "date-fns"
import { CalendarIcon } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { timeOfDayOf, withDayOf, withTimeOfDay } from "@/lib/logged-at"
const DAY_LABEL = "d MMM yyyy"
interface LoggedAtPickerProps {
value: Date
onChange: (next: Date) => void
}
export function LoggedAtPicker({ value, onChange }: LoggedAtPickerProps) {
const [calendarOpen, setCalendarOpen] = useState(false)
const pickDay = (day: Date | undefined) => {
if (!day) return
onChange(withDayOf(value, day))
setCalendarOpen(false)
}
return (
<div className="flex items-center gap-2">
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"font-normal"
)}
>
<CalendarIcon />
{format(value, DAY_LABEL)}
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
mode="single"
selected={value}
defaultMonth={value}
onSelect={pickDay}
autoFocus
/>
</PopoverContent>
</Popover>
<Input
type="time"
className="w-28"
value={timeOfDayOf(value)}
onChange={(event) => onChange(withTimeOfDay(value, event.target.value))}
/>
</div>
)
}

View File

@@ -22,7 +22,7 @@ export function WizardUpload({ onParsed }: WizardUploadProps) {
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
const file = e.dataTransfer.files[0]
const file = Array.from(e.dataTransfer.files).at(0)
if (file) handleFile(file)
}

View File

@@ -0,0 +1,88 @@
import {
Activity,
BedDouble,
EyeOff,
Footprints,
HeartPulse,
Smartphone,
Timer,
Wine,
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import type { MetricKind } from "@/api/schema"
export type MetricField = {
kind: MetricKind
label: string
unit: string
min: number
max: number
icon: LucideIcon
}
export const METRIC_FIELDS: MetricField[] = [
{
kind: "steps",
label: "Steps",
unit: "steps",
min: 0,
max: 200000,
icon: Footprints,
},
{
kind: "sleepMinutes",
label: "Sleep",
unit: "minutes",
min: 0,
max: 1440,
icon: BedDouble,
},
{
kind: "awakeMinutes",
label: "Awake in bed",
unit: "minutes",
min: 0,
max: 1440,
icon: EyeOff,
},
{
kind: "restingHeartRate",
label: "Resting heart rate",
unit: "bpm",
min: 25,
max: 120,
icon: HeartPulse,
},
{
kind: "hrv",
label: "HRV",
unit: "ms",
min: 1,
max: 300,
icon: Activity,
},
{
kind: "exerciseMinutes",
label: "Exercise",
unit: "minutes",
min: 0,
max: 1440,
icon: Timer,
},
{
kind: "screenTimeMinutes",
label: "Screen time",
unit: "minutes",
min: 0,
max: 1440,
icon: Smartphone,
},
{
kind: "alcoholicDrinks",
label: "Alcoholic drinks",
unit: "standard drinks",
min: 0,
max: 60,
icon: Wine,
},
]

View File

@@ -0,0 +1,46 @@
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { DailyMetricResponse } from "@/api/schema"
import type { MetricField } from "./metric-fields"
type MetricRowProps = {
field: MetricField
stored: DailyMetricResponse | undefined
draft: string
onChange: (draft: string) => void
}
export function MetricRow({ field, stored, draft, onChange }: MetricRowProps) {
const Icon = field.icon
return (
<div className="flex flex-col gap-1.5">
<Label className="flex items-center gap-2 text-xs">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
{field.label}
<span className="text-muted-foreground">({field.unit})</span>
</Label>
<Input
type="number"
inputMode="numeric"
min={field.min}
max={field.max}
placeholder="Not recorded"
value={draft}
onChange={(event) => onChange(event.target.value)}
/>
{stored?.provider ? (
<p className="text-xs text-muted-foreground">
Reported by {stored.provider}. Saving replaces it with your own; empty
the field to clear it, and a later import may report it again.
</p>
) : (
stored && (
<p className="text-xs text-muted-foreground">
Entered by you. Empty the field to clear it.
</p>
)
)}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { MOODS, type MoodValue } from "@/lib/mood"
import { MOODS } from "@/lib/mood"
import type { MoodValue } from "@/lib/mood"
import { cn } from "@/lib/utils"
interface MoodPickerProps {

View File

@@ -0,0 +1,137 @@
import { useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "@/components/ui/toast"
import {
useApiTokens,
useMintApiToken,
useRevokeApiToken,
} from "@/hooks/use-api-tokens"
import type { ApiTokenResponse } from "@/api/schema"
const NAME_RULE = /^[a-z0-9-]+$/
export function ApiTokensCard() {
const { data: existing } = useApiTokens()
const mint = useMintApiToken()
const revoke = useRevokeApiToken()
const [name, setName] = useState("")
const [justMinted, setJustMinted] = useState<string | null>(null)
const normalised = name.trim().toLowerCase()
const canMint = normalised.length > 0 && NAME_RULE.test(normalised)
const submit = () => {
mint.mutate(normalised, {
onSuccess: (minted) => {
setJustMinted(minted.secret)
setName("")
},
onError: (error) =>
toast.add({
title: error instanceof Error ? error.message : "Could not mint",
type: "error",
}),
})
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Importer tokens</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
For automations that send daily metrics a Shortcut, Tasker, a cron
script. A token can only write metrics: it cannot read your entries or
touch your account. Tokens do not expire, so revoke any you stop
using.
</p>
{justMinted && (
<div className="flex flex-col gap-1.5 rounded-md border border-dashed p-3">
<Label className="text-xs">
Copy this now it is not shown again
</Label>
<code className="text-xs break-all">{justMinted}</code>
<Button
variant="outline"
size="sm"
onClick={() => setJustMinted(null)}
>
Done
</Button>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label className="text-xs">
Name becomes the source recorded on its data
</Label>
<div className="flex gap-2">
<Input
placeholder="iphone-shortcuts"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<Button
size="sm"
disabled={!canMint || mint.isPending}
onClick={submit}
>
{mint.isPending ? "Minting…" : "Mint"}
</Button>
</div>
{normalised.length > 0 && !canMint && (
<span className="text-xs text-destructive">
Lowercase letters, digits and hyphens only.
</span>
)}
</div>
{existing?.length ? (
<div className="flex flex-col gap-2">
{existing.map((token) => (
<TokenRow
key={token.id}
token={token}
onRevoke={() => revoke.mutate(token.id)}
/>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">No tokens yet.</span>
)}
</CardContent>
</Card>
)
}
function TokenRow({
token,
onRevoke,
}: {
token: ApiTokenResponse
onRevoke: () => void
}) {
const lastUsed = token.lastUsedAt
? `used ${formatDistanceToNow(new Date(token.lastUsedAt), { addSuffix: true })}`
: "never used"
return (
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium">{token.name}</span>
<span className="text-[10px] text-muted-foreground">{lastUsed}</span>
</div>
<Button variant="ghost" size="icon" onClick={onRevoke}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
)
}

View File

@@ -0,0 +1,101 @@
import { useState } from "react"
import { format } from "date-fns"
import { Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import {
useCycle,
useForgetCycleStart,
usePreferences,
useRecordCycleStart,
useSetCycleTracking,
} from "@/hooks/use-cycle"
export function CycleCard() {
const { data: preferences } = usePreferences()
const setTracking = useSetCycleTracking()
const tracking = preferences?.tracksCycle ?? false
const { data: view } = useCycle(tracking)
const today = format(new Date(), "yyyy-MM-dd")
const [start, setStart] = useState(today)
const record = useRecordCycleStart()
const forget = useForgetCycleStart()
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm">Cycle</CardTitle>
<Switch
checked={tracking}
onCheckedChange={(next) => setTracking.mutate(next)}
/>
</CardHeader>
{tracking && (
<CardContent className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Record the day a period begins once a cycle, not once a day. The
cycle day for every other date is worked out from it, so correcting
a start fixes everything that followed.
</p>
{view?.today ? (
<p className="text-xs">
Today is <span className="font-medium">day {view.today.day}</span>{" "}
of a cycle that usually runs {view.usualLength} days.
</p>
) : (
<p className="text-xs text-muted-foreground">
No cycle day for today yet record a start below.
</p>
)}
<div className="flex flex-col gap-1.5">
<Label className="text-xs">A period began on</Label>
<div className="flex gap-2">
<Input
type="date"
value={start}
max={today}
onChange={(event) => setStart(event.target.value)}
/>
<Button
size="sm"
disabled={record.isPending}
onClick={() => record.mutate(start)}
>
Record
</Button>
</div>
</div>
{view?.starts.length ? (
<div className="flex flex-col gap-1">
{[...view.starts].reverse().map((day) => (
<div key={day} className="flex items-center justify-between">
<span className="text-xs">{day}</span>
<Button
variant="ghost"
size="icon"
onClick={() => forget.mutate(day)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">
Nothing recorded yet.
</span>
)}
</CardContent>
)}
</Card>
)
}

View File

@@ -0,0 +1,68 @@
import { formatDistanceToNow } from "date-fns"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { useRejections } from "@/hooks/use-rejections"
import type { RejectionResponse } from "@/api/schema"
export function RejectionsCard() {
const { data: rejections, isLoading } = useRejections()
if (isLoading) return <Skeleton className="h-24" />
if (!rejections?.length) return null
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">
Readings that could not be used
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Nothing here was stored. An automation cannot show you an error, so
whatever it sent that could not be read is listed here instead. Only
the most recent are kept.
</p>
{rejections.map((rejection) => (
<RejectionRow key={rejection.id} rejection={rejection} />
))}
</CardContent>
</Card>
)
}
function RejectionRow({ rejection }: { rejection: RejectionResponse }) {
const source =
rejection.origin === "storedRow"
? "stored by an older build"
: (rejection.provider ?? "an importer")
return (
<div className="flex flex-col gap-0.5 border-l-2 border-destructive/40 pl-2">
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-medium">
{rejection.kind}
{rejection.value !== null && (
<span className="font-normal text-muted-foreground">
{" "}
= {rejection.value}
</span>
)}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{rejection.date ?? "no date"}
</span>
</div>
<span className="text-[10px] text-muted-foreground">
{rejection.reason}
</span>
<span className="text-[10px] text-muted-foreground">
from {source},{" "}
{formatDistanceToNow(new Date(rejection.recordedAt), {
addSuffix: true,
})}
</span>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/toast"
import {
useConnectProvider,
useDisconnectProvider,
useProviderConnections,
} from "@/hooks/use-providers"
const PROVIDER = "subsonic"
export function SubsonicCard() {
const connections = useProviderConnections()
const connect = useConnectProvider(PROVIDER)
const disconnect = useDisconnectProvider(PROVIDER)
const [url, setUrl] = useState("")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const connected =
connections.data?.some((c) => c.provider === PROVIDER) ?? false
const submit = () => {
connect.mutate(
{ url, username, password },
{
onSuccess: () => {
setPassword("")
toast.add({ title: "Subsonic connected", type: "success" })
},
onError: () => toast.add({ title: "Could not connect", type: "error" }),
}
)
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Music</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{connected ? (
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
Subsonic is connected. Your password is stored encrypted and is
never shown again.
</p>
<Button
variant="outline"
size="sm"
onClick={() => disconnect.mutate()}
disabled={disconnect.isPending}
>
Disconnect
</Button>
</div>
) : (
<>
<p className="text-xs text-muted-foreground">
Connect your own Subsonic server to fill in what you were
listening to.
</p>
<Input
placeholder="https://music.example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Input
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
size="sm"
onClick={submit}
disabled={connect.isPending || !url || !username || !password}
>
{connect.isPending ? "Connecting…" : "Connect"}
</Button>
</>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,67 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { useActivities } from "@/hooks/use-activities"
import { useAllCorrelations } from "@/hooks/use-correlation"
export function ActivityCorrelations() {
const { data: activities } = useActivities()
const activeIds = (activities ?? [])
.filter((a) => !a.archived)
.map((a) => a.id)
const { data: correlations, isLoading } = useAllCorrelations(activeIds)
const activityMap = new Map((activities ?? []).map((a) => [a.id, a.name]))
const sorted = [...(correlations ?? [])].sort(
(a, b) => Math.abs(b.correlation!) - Math.abs(a.correlation!)
)
if (isLoading) return <Skeleton className="h-32" />
if (!sorted.length) return null
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Activity impact on mood</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{sorted.slice(0, 8).map((c) => (
<CorrelationBar
key={c.activityId}
name={activityMap.get(c.activityId) ?? c.activityId}
value={c.correlation!}
/>
))}
</CardContent>
</Card>
)
}
function CorrelationBar({ name, value }: { name: string; value: number }) {
const pct = Math.abs(value) * 100
const positive = value > 0
return (
<div className="flex items-center gap-3">
<span className="w-24 shrink-0 truncate text-xs">{name}</span>
<div className="relative h-4 flex-1 rounded-full bg-muted/50">
<div
className={`absolute top-0 h-full rounded-full ${
positive ? "left-1/2 bg-green-500/60" : "right-1/2 bg-red-500/60"
}`}
style={{ width: `${pct / 2}%` }}
/>
<div className="absolute top-0 left-1/2 h-full w-px bg-muted-foreground/30" />
</div>
<span
className={`w-12 shrink-0 text-right text-xs font-medium ${
positive ? "text-green-400" : "text-red-400"
}`}
>
{positive ? "+" : ""}
{value.toFixed(2)}
</span>
</div>
)
}

View File

@@ -0,0 +1,148 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { METRIC_FIELDS } from "@/components/metric/metric-fields"
import { useMoodCorrelations } from "@/hooks/use-mood-correlations"
import type { CorrelationRowResponse } from "@/api/schema"
const STRATEGY_LABELS: Record<string, string> = {
pearson: "linear",
spearman: "rank",
kendall: "pairwise",
meanDifference: "mean difference",
}
function labelOf(row: CorrelationRowResponse): string {
const input = row.input
switch (input.kind) {
case "metric": {
const field = METRIC_FIELDS.find((f) => f.kind === input.metric)
return field ? field.label : input.metric
}
case "moonPhase":
return "Moon phase"
case "activity":
return input.name
}
}
function orderOf(row: CorrelationRowResponse): number {
const input = row.input
switch (input.kind) {
case "metric":
return METRIC_FIELDS.findIndex((f) => f.kind === input.metric)
case "moonPhase":
return METRIC_FIELDS.length
case "activity":
return METRIC_FIELDS.length + 1
}
}
export function MoodCorrelations() {
const { data: rows, isLoading } = useMoodCorrelations()
if (isLoading) return <Skeleton className="h-40" />
if (!rows) return null
const withData = rows
.filter((row) => row.sampleSize > 0)
.sort((left, right) => orderOf(left) - orderOf(right))
if (!withData.length) return null
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">What moves with your mood</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">
Over the last year. Several methods are run on each row and all of
them are shown how many point the same way matters more than any one
number. Nothing here is ranked, and none of it is evidence on its own.
</p>
<p className="text-xs text-muted-foreground">
A dot marks a reading that still stood once the number of things
compared was accounted for. It is a second thing to look at, not a
verdict.
</p>
{withData.map((row) => (
<CorrelationRow key={rowKey(row)} row={row} />
))}
</CardContent>
</Card>
)
}
function rowKey(row: CorrelationRowResponse): string {
const input = row.input
switch (input.kind) {
case "metric":
return `metric:${input.metric}`
case "moonPhase":
return "moonPhase"
case "activity":
return `activity:${input.activityId}`
}
}
function CorrelationRow({ row }: { row: CorrelationRowResponse }) {
const scored = row.scores.length > 0
const isControl = row.input.kind === "moonPhase"
return (
<div className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-3">
<span className="truncate text-xs font-medium">{labelOf(row)}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{agreementLine(row)}
</span>
</div>
{scored ? (
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{row.scores.map((score) => (
<span
key={score.strategy}
className="text-[10px] text-muted-foreground tabular-nums"
>
{STRATEGY_LABELS[score.strategy] ?? score.strategy}{" "}
<span className="font-medium text-foreground">
{score.coefficient.toFixed(2)}
</span>
{score.heldUp && (
<span
className="ml-0.5 text-foreground"
title="still stood once the number of comparisons was accounted for"
>
</span>
)}
</span>
))}
</div>
) : (
<span className="text-[10px] text-muted-foreground">
{row.sampleSize} days so far not enough to say anything
</span>
)}
{isControl && (
<span className="text-[10px] text-muted-foreground">
a control: the moon should show nothing, so a strong reading here
means the rows above are noisier than they look
</span>
)}
</div>
)
}
function agreementLine(row: CorrelationRowResponse): string {
if (row.agreement.applicable === 1) return "one measure only"
if (row.scores.length === 0) return `${row.sampleSize} days`
return `${row.agreement.agreeing} of ${row.agreement.applicable} agree`
}

View File

@@ -46,8 +46,8 @@ export function MoodHeatmap({ year, days }: MoodHeatmapProps) {
const moodMap = new Map<string, number>()
for (const day of days) {
if (day.dominantMood) {
moodMap.set(day.date, day.dominantMood)
if (day.mood) {
moodMap.set(day.date, day.mood)
}
}
@@ -92,7 +92,7 @@ export function MoodHeatmap({ year, days }: MoodHeatmapProps) {
key={weekIdx}
className="w-[11px] text-center text-[8px] leading-[11px] text-muted-foreground"
>
{showLabel ? MONTH_LABELS[firstDayInWeek!.getMonth()] : ""}
{showLabel ? MONTH_LABELS[firstDayInWeek.getMonth()] : ""}
</div>
)
})}

View File

@@ -69,8 +69,14 @@ export function MoodTrendChart({
width={32}
/>
<Tooltip
content={({ active, payload }) => {
content={(props) => {
const { active, payload } = props as {
active?: boolean
payload?: ReadonlyArray<{ payload: DataPoint }>
}
if (!active || !payload?.length) return null
const d = payload[0].payload
const mood = Math.round(d.avg)
return (

View File

@@ -1,5 +1,6 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

View File

@@ -1,7 +1,8 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

View File

@@ -1,6 +1,7 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

View File

@@ -1,7 +1,8 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

View File

@@ -1,6 +1,7 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"

View File

@@ -1,5 +1,6 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

View File

@@ -1,12 +1,8 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { DayPicker, getDefaultClassNames } from "react-day-picker"
import type { DayButton, Locale } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"

View File

@@ -1,7 +1,6 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import useEmblaCarousel from "embla-carousel-react"
import type { UseEmblaCarouselType } from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

View File

@@ -347,15 +347,13 @@ function getPayloadConfigFromPayload(
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
configLabelKey = payload[key as keyof typeof payload]
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
configLabelKey = payloadPayload[key as keyof typeof payloadPayload]
}
return configLabelKey in config ? config[configLabelKey] : config[key]

View File

@@ -1,4 +1,5 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

View File

@@ -1,5 +1,6 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"

View File

@@ -1,7 +1,8 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

View File

@@ -1,7 +1,8 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"

View File

@@ -1,7 +1,8 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

View File

@@ -4,7 +4,8 @@ import * as React from "react"
import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire"
import { cn } from "@/lib/utils"
import { buttonVariants, type Button } from "@/components/ui/button"
import { buttonVariants } from "@/components/ui/button"
import type { Button } from "@/components/ui/button"
import { CheckIcon } from "lucide-react"
function Questionnaire({

View File

@@ -3,7 +3,8 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"

View File

@@ -1,7 +1,8 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"

View File

@@ -1,5 +1,6 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

View File

@@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { tokens } from "@/api/client"
const TOKENS = ["api-tokens"]
export function useApiTokens() {
return useQuery({
queryKey: TOKENS,
queryFn: () => tokens.list(),
})
}
export function useMintApiToken() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (name: string) => tokens.mint(name),
onSuccess: () => queryClient.invalidateQueries({ queryKey: TOKENS }),
})
}
export function useRevokeApiToken() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => tokens.revoke(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: TOKENS }),
})
}

View File

@@ -1,23 +0,0 @@
import { useQuery } from "@tanstack/react-query"
import { entries } from "@/api/client"
export function useCorrelation(activityId: string | null) {
return useQuery({
queryKey: ["correlation", activityId],
queryFn: () => entries.correlation(activityId!),
enabled: !!activityId,
})
}
export function useAllCorrelations(activityIds: string[]) {
return useQuery({
queryKey: ["correlations", activityIds],
queryFn: async () => {
const results = await Promise.all(
activityIds.map((id) => entries.correlation(id))
)
return results.filter((r) => r.correlation !== null)
},
enabled: activityIds.length > 0,
})
}

View File

@@ -0,0 +1,57 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { cycle } from "@/api/client"
const CYCLE = ["cycle"]
const PREFERENCES = ["preferences"]
export function usePreferences() {
return useQuery({
queryKey: PREFERENCES,
queryFn: () => cycle.preferences(),
})
}
export function useSetCycleTracking() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (tracksCycle: boolean) => cycle.setTracking(tracksCycle),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PREFERENCES })
queryClient.invalidateQueries({ queryKey: CYCLE })
queryClient.invalidateQueries({ queryKey: ["calendar"] })
},
})
}
export function useCycle(enabled: boolean) {
return useQuery({
queryKey: CYCLE,
queryFn: () => cycle.read(),
enabled,
})
}
export function useRecordCycleStart() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (date: string) => cycle.record(date),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CYCLE })
queryClient.invalidateQueries({ queryKey: ["calendar"] })
},
})
}
export function useForgetCycleStart() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (date: string) => cycle.forget(date),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CYCLE })
queryClient.invalidateQueries({ queryKey: ["calendar"] })
},
})
}

View File

@@ -1,17 +1,53 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { data } from "@/api/client"
export function useExport() {
function today(): string {
return new Date().toISOString().slice(0, 10)
}
function save(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
export function useCompleteBackup() {
return useMutation({
meta: { success: "Export downloaded" },
mutationFn: () => data.export(),
onSuccess: (blob) => {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `k-mood-export-${new Date().toISOString().slice(0, 10)}.zip`
a.click()
URL.revokeObjectURL(url)
meta: { success: "Complete backup downloaded" },
mutationFn: () => data.completeBackup(),
onSuccess: (blob) => save(blob, `k-mood-complete-backup-${today()}.zip`),
})
}
export function useShareableExtract() {
return useMutation({
meta: { success: "Shareable journal downloaded" },
mutationFn: () => data.shareableExtract(),
onSuccess: (blob) => save(blob, `k-mood-shareable-journal-${today()}.md`),
})
}
export function useRestore() {
const queryClient = useQueryClient()
return useMutation({
meta: { success: "Backup restored" },
mutationFn: (file: File) => data.restore(file),
onSuccess: () => {
for (const key of [
"entries",
"activities",
"stats",
"calendar",
"daily-metrics",
"cycle",
"preferences",
]) {
queryClient.invalidateQueries({ queryKey: [key] })
}
},
})
}

View File

@@ -0,0 +1,32 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { metrics } from "@/api/client"
import type {
DailyMetricResponse,
MetricKind,
MetricPayload,
} from "@/api/schema"
const METRICS = ["daily-metrics"]
export function useDailyMetrics(from: string, to: string) {
return useQuery({
queryKey: [...METRICS, from, to],
queryFn: () => metrics.list({ from, to }),
})
}
export function useSetDailyMetrics() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ date, values }: { date: string; values: MetricPayload[] }) =>
metrics.set(date, values),
onSuccess: () => queryClient.invalidateQueries({ queryKey: METRICS }),
})
}
export function byKind(
stored: DailyMetricResponse[] | undefined
): Map<MetricKind, DailyMetricResponse> {
return new Map(stored?.map((metric) => [metric.kind, metric]) ?? [])
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query"
import { format, subDays } from "date-fns"
import { correlations } from "@/api/client"
const A_YEAR = 365
export function useMoodCorrelations() {
const to = format(new Date(), "yyyy-MM-dd")
const from = format(subDays(new Date(), A_YEAR), "yyyy-MM-dd")
return useQuery({
queryKey: ["mood-correlations", from, to],
queryFn: () => correlations.list({ from, to }),
})
}

View File

@@ -0,0 +1,37 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { providers } from "@/api/client"
import type { SubsonicCredential } from "@/api/schema"
const CONNECTIONS = ["provider-connections"]
export function useProviderConnections() {
return useQuery({
queryKey: CONNECTIONS,
queryFn: () => providers.list(),
})
}
export function useConnectProvider(provider: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (credential: SubsonicCredential) =>
providers.connect(provider, credential),
onSuccess: () => queryClient.invalidateQueries({ queryKey: CONNECTIONS }),
})
}
export function useDisconnectProvider(provider: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => providers.disconnect(provider),
onSuccess: () => queryClient.invalidateQueries({ queryKey: CONNECTIONS }),
})
}
export function useNowPlaying() {
return useMutation({
mutationFn: () => providers.nowPlaying(),
})
}

View File

@@ -20,6 +20,26 @@ function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function sameKey(a: ArrayBuffer | null, b: Uint8Array): boolean {
if (!a) return false
const bytes = new Uint8Array(a)
return bytes.length === b.length && bytes.every((v, i) => v === b[i])
}
// A subscription created with an older VAPID key makes subscribe() throw
// InvalidStateError, so drop it before asking for a new one.
async function dropStaleSubscription(
reg: ServiceWorkerRegistration,
keyBytes: Uint8Array
): Promise<void> {
const existing = await reg.pushManager.getSubscription()
if (!existing) return
if (sameKey(existing.options.applicationServerKey, keyBytes)) return
await push.unsubscribe(existing.endpoint).catch(() => {})
await existing.unsubscribe()
}
export function usePushNotifications() {
const [isSubscribed, setIsSubscribed] = useState(false)
const [isSupported, setIsSupported] = useState(false)
@@ -55,6 +75,8 @@ export function usePushNotifications() {
const reg = await navigator.serviceWorker.ready
const keyBytes = urlBase64ToUint8Array(vapidKey.data.publicKey)
await dropStaleSubscription(reg, keyBytes)
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: keyBytes.buffer as ArrayBuffer,

View File

@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query"
import { metrics } from "@/api/client"
export function useRejections() {
return useQuery({
queryKey: ["metric-rejections"],
queryFn: () => metrics.rejections(),
})
}

View File

@@ -49,7 +49,7 @@ export function usePeriodEntries(period: "week" | "month" | "year" | "all") {
const days = await entries.calendar(range)
return days.map((day) => ({
loggedAt: day.date + "T12:00:00+00:00",
mood: day.dominantMood ?? 3,
mood: day.dayMood ?? 3,
}))
}

View File

@@ -0,0 +1,33 @@
import { useEffect } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { users } from "@/api/client"
function platformTimezone(): string | null {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || null
} catch {
return null
}
}
export function useEnsureTimezone() {
const queryClient = useQueryClient()
const profile = useQuery({
queryKey: ["me"],
queryFn: () => users.me(),
})
const adopt = useMutation({
mutationFn: (timezone: string) => users.updateProfile({ timezone }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["me"] }),
})
const missing = profile.data !== undefined && profile.data.timezone === null
const detected = platformTimezone()
useEffect(() => {
if (!missing || !detected || adopt.isPending || adopt.isSuccess) return
adopt.mutate(detected)
}, [missing, detected, adopt])
}

72
spa/src/lib/dimensions.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { DimensionPayload, EntryResponse } from "@/api/schema"
function find<TKind extends DimensionPayload["kind"]>(
entry: EntryResponse,
kind: TKind
): Extract<DimensionPayload, { kind: TKind }> | undefined {
return entry.dimensions.find((d) => d.kind === kind) as
Extract<DimensionPayload, { kind: TKind }> | undefined
}
export function contentOf(entry: EntryResponse): string | null {
return find(entry, "content")?.text ?? null
}
export function activitiesOf(entry: EntryResponse): string[] {
return find(entry, "activities")?.ids ?? []
}
export function photosOf(entry: EntryResponse): string[] {
return find(entry, "photos")?.ids ?? []
}
export function voiceMemosOf(entry: EntryResponse): string[] {
return find(entry, "voiceMemos")?.ids ?? []
}
export function locationOf(entry: EntryResponse) {
const location = find(entry, "location")
return location
? { latitude: location.latitude, longitude: location.longitude }
: null
}
export function songOf(entry: EntryResponse) {
return find(entry, "song") ?? null
}
export function weatherOf(entry: EntryResponse) {
return find(entry, "weather") ?? null
}
export function buildDimensions(input: {
content: string
activityIds: string[]
photoIds: string[]
voiceMemoIds: string[]
location: { latitude: number; longitude: number } | null
song: { title: string; artist: string } | null
}): DimensionPayload[] {
const dimensions: DimensionPayload[] = []
const text = input.content.trim()
if (text) dimensions.push({ kind: "content", text })
if (input.activityIds.length)
dimensions.push({ kind: "activities", ids: input.activityIds })
if (input.photoIds.length)
dimensions.push({ kind: "photos", ids: input.photoIds })
if (input.voiceMemoIds.length)
dimensions.push({ kind: "voiceMemos", ids: input.voiceMemoIds })
if (input.location)
dimensions.push({
kind: "location",
latitude: input.location.latitude,
longitude: input.location.longitude,
})
const title = input.song?.title.trim()
const artist = input.song?.artist.trim()
if (title && artist) dimensions.push({ kind: "song", title, artist })
return dimensions
}

31
spa/src/lib/logged-at.ts Normal file
View File

@@ -0,0 +1,31 @@
import { format } from "date-fns"
const RFC3339_WITH_LOCAL_OFFSET = "yyyy-MM-dd'T'HH:mm:ssXXX"
const TIME_OF_DAY = "HH:mm"
export function toLoggedAt(moment: Date): string {
return format(moment, RFC3339_WITH_LOCAL_OFFSET)
}
export function fromLoggedAt(value: string): Date {
return new Date(value)
}
export function timeOfDayOf(moment: Date): string {
return format(moment, TIME_OF_DAY)
}
export function withDayOf(moment: Date, day: Date): Date {
const moved = new Date(moment)
moved.setFullYear(day.getFullYear(), day.getMonth(), day.getDate())
return moved
}
export function withTimeOfDay(moment: Date, timeOfDay: string): Date {
const [hours, minutes] = timeOfDay.split(":").map(Number)
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return moment
const moved = new Date(moment)
moved.setHours(hours, minutes, 0, 0)
return moved
}

View File

@@ -1,4 +1,5 @@
import { clsx, type ClassValue } from "clsx"
import { clsx } from "clsx"
import type { ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {

View File

@@ -6,8 +6,7 @@ import {
QueryClientProvider,
} from "@tanstack/react-query"
import { getRouter } from "./router"
import { toast } from "@/components/ui/toast"
import { Toaster } from "@/components/ui/toast"
import { toast, Toaster } from "@/components/ui/toast"
import { ApiError } from "@/api/client"
import "./styles.css"

View File

@@ -8,198 +8,210 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root"
import { Route as AuthRouteImport } from "./routes/_auth"
import { Route as LoginRouteImport } from "./routes/login"
import { Route as RegisterRouteImport } from "./routes/register"
import { Route as AuthIndexRouteImport } from "./routes/_auth/index"
import { Route as AuthActivitiesRouteImport } from "./routes/_auth/activities"
import { Route as AuthAddRouteImport } from "./routes/_auth/add"
import { Route as AuthCalendarRouteImport } from "./routes/_auth/calendar"
import { Route as AuthDiaryRouteImport } from "./routes/_auth/diary"
import { Route as AuthExportRouteImport } from "./routes/_auth/export"
import { Route as AuthImportRouteImport } from "./routes/_auth/import"
import { Route as AuthRemindersRouteImport } from "./routes/_auth/reminders"
import { Route as AuthSettingsRouteImport } from "./routes/_auth/settings"
import { Route as AuthStatsRouteImport } from "./routes/_auth/stats"
import { Route as AuthEditIdRouteImport } from "./routes/_auth/edit.$id"
import { Route as AuthEntryIdRouteImport } from "./routes/_auth/entry.$id"
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as LoginRouteImport } from './routes/login'
import { Route as RegisterRouteImport } from './routes/register'
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthActivitiesRouteImport } from './routes/_auth/activities'
import { Route as AuthAddRouteImport } from './routes/_auth/add'
import { Route as AuthCalendarRouteImport } from './routes/_auth/calendar'
import { Route as AuthDiaryRouteImport } from './routes/_auth/diary'
import { Route as AuthExportRouteImport } from './routes/_auth/export'
import { Route as AuthImportRouteImport } from './routes/_auth/import'
import { Route as AuthMetricsRouteImport } from './routes/_auth/metrics'
import { Route as AuthRemindersRouteImport } from './routes/_auth/reminders'
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
import { Route as AuthStatsRouteImport } from './routes/_auth/stats'
import { Route as AuthEditIdRouteImport } from './routes/_auth/edit.$id'
import { Route as AuthEntryIdRouteImport } from './routes/_auth/entry.$id'
const AuthRoute = AuthRouteImport.update({
id: "/_auth",
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: "/login",
path: "/login",
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const RegisterRoute = RegisterRouteImport.update({
id: "/register",
path: "/register",
id: '/register',
path: '/register',
getParentRoute: () => rootRouteImport,
} as any)
const AuthIndexRoute = AuthIndexRouteImport.update({
id: "/",
path: "/",
id: '/',
path: '/',
getParentRoute: () => AuthRoute,
} as any)
const AuthActivitiesRoute = AuthActivitiesRouteImport.update({
id: "/activities",
path: "/activities",
id: '/activities',
path: '/activities',
getParentRoute: () => AuthRoute,
} as any)
const AuthAddRoute = AuthAddRouteImport.update({
id: "/add",
path: "/add",
id: '/add',
path: '/add',
getParentRoute: () => AuthRoute,
} as any)
const AuthCalendarRoute = AuthCalendarRouteImport.update({
id: "/calendar",
path: "/calendar",
id: '/calendar',
path: '/calendar',
getParentRoute: () => AuthRoute,
} as any)
const AuthDiaryRoute = AuthDiaryRouteImport.update({
id: "/diary",
path: "/diary",
id: '/diary',
path: '/diary',
getParentRoute: () => AuthRoute,
} as any)
const AuthExportRoute = AuthExportRouteImport.update({
id: "/export",
path: "/export",
id: '/export',
path: '/export',
getParentRoute: () => AuthRoute,
} as any)
const AuthImportRoute = AuthImportRouteImport.update({
id: "/import",
path: "/import",
id: '/import',
path: '/import',
getParentRoute: () => AuthRoute,
} as any)
const AuthMetricsRoute = AuthMetricsRouteImport.update({
id: '/metrics',
path: '/metrics',
getParentRoute: () => AuthRoute,
} as any)
const AuthRemindersRoute = AuthRemindersRouteImport.update({
id: "/reminders",
path: "/reminders",
id: '/reminders',
path: '/reminders',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRoute = AuthSettingsRouteImport.update({
id: "/settings",
path: "/settings",
id: '/settings',
path: '/settings',
getParentRoute: () => AuthRoute,
} as any)
const AuthStatsRoute = AuthStatsRouteImport.update({
id: "/stats",
path: "/stats",
id: '/stats',
path: '/stats',
getParentRoute: () => AuthRoute,
} as any).lazy(() => import("./routes/_auth/stats.lazy").then((d) => d.Route))
} as any).lazy(() => import('./routes/_auth/stats.lazy').then((d) => d.Route))
const AuthEditIdRoute = AuthEditIdRouteImport.update({
id: "/edit/$id",
path: "/edit/$id",
id: '/edit/$id',
path: '/edit/$id',
getParentRoute: () => AuthRoute,
} as any)
const AuthEntryIdRoute = AuthEntryIdRouteImport.update({
id: "/entry/$id",
path: "/entry/$id",
id: '/entry/$id',
path: '/entry/$id',
getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
"/": typeof AuthIndexRoute
"/login": typeof LoginRoute
"/register": typeof RegisterRoute
"/activities": typeof AuthActivitiesRoute
"/add": typeof AuthAddRoute
"/calendar": typeof AuthCalendarRoute
"/diary": typeof AuthDiaryRoute
"/export": typeof AuthExportRoute
"/import": typeof AuthImportRoute
"/reminders": typeof AuthRemindersRoute
"/settings": typeof AuthSettingsRoute
"/stats": typeof AuthStatsRoute
"/edit/$id": typeof AuthEditIdRoute
"/entry/$id": typeof AuthEntryIdRoute
'/': typeof AuthIndexRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/activities': typeof AuthActivitiesRoute
'/add': typeof AuthAddRoute
'/calendar': typeof AuthCalendarRoute
'/diary': typeof AuthDiaryRoute
'/export': typeof AuthExportRoute
'/import': typeof AuthImportRoute
'/metrics': typeof AuthMetricsRoute
'/reminders': typeof AuthRemindersRoute
'/settings': typeof AuthSettingsRoute
'/stats': typeof AuthStatsRoute
'/edit/$id': typeof AuthEditIdRoute
'/entry/$id': typeof AuthEntryIdRoute
}
export interface FileRoutesByTo {
"/login": typeof LoginRoute
"/register": typeof RegisterRoute
"/activities": typeof AuthActivitiesRoute
"/add": typeof AuthAddRoute
"/calendar": typeof AuthCalendarRoute
"/diary": typeof AuthDiaryRoute
"/export": typeof AuthExportRoute
"/import": typeof AuthImportRoute
"/reminders": typeof AuthRemindersRoute
"/settings": typeof AuthSettingsRoute
"/stats": typeof AuthStatsRoute
"/": typeof AuthIndexRoute
"/edit/$id": typeof AuthEditIdRoute
"/entry/$id": typeof AuthEntryIdRoute
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/activities': typeof AuthActivitiesRoute
'/add': typeof AuthAddRoute
'/calendar': typeof AuthCalendarRoute
'/diary': typeof AuthDiaryRoute
'/export': typeof AuthExportRoute
'/import': typeof AuthImportRoute
'/metrics': typeof AuthMetricsRoute
'/reminders': typeof AuthRemindersRoute
'/settings': typeof AuthSettingsRoute
'/stats': typeof AuthStatsRoute
'/': typeof AuthIndexRoute
'/edit/$id': typeof AuthEditIdRoute
'/entry/$id': typeof AuthEntryIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
"/_auth": typeof AuthRouteWithChildren
"/login": typeof LoginRoute
"/register": typeof RegisterRoute
"/_auth/activities": typeof AuthActivitiesRoute
"/_auth/add": typeof AuthAddRoute
"/_auth/calendar": typeof AuthCalendarRoute
"/_auth/diary": typeof AuthDiaryRoute
"/_auth/export": typeof AuthExportRoute
"/_auth/import": typeof AuthImportRoute
"/_auth/reminders": typeof AuthRemindersRoute
"/_auth/settings": typeof AuthSettingsRoute
"/_auth/stats": typeof AuthStatsRoute
"/_auth/": typeof AuthIndexRoute
"/_auth/edit/$id": typeof AuthEditIdRoute
"/_auth/entry/$id": typeof AuthEntryIdRoute
'/_auth': typeof AuthRouteWithChildren
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/_auth/activities': typeof AuthActivitiesRoute
'/_auth/add': typeof AuthAddRoute
'/_auth/calendar': typeof AuthCalendarRoute
'/_auth/diary': typeof AuthDiaryRoute
'/_auth/export': typeof AuthExportRoute
'/_auth/import': typeof AuthImportRoute
'/_auth/metrics': typeof AuthMetricsRoute
'/_auth/reminders': typeof AuthRemindersRoute
'/_auth/settings': typeof AuthSettingsRoute
'/_auth/stats': typeof AuthStatsRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/edit/$id': typeof AuthEditIdRoute
'/_auth/entry/$id': typeof AuthEntryIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| "/"
| "/login"
| "/register"
| "/activities"
| "/add"
| "/calendar"
| "/diary"
| "/export"
| "/import"
| "/reminders"
| "/settings"
| "/stats"
| "/edit/$id"
| "/entry/$id"
| '/'
| '/login'
| '/register'
| '/activities'
| '/add'
| '/calendar'
| '/diary'
| '/export'
| '/import'
| '/metrics'
| '/reminders'
| '/settings'
| '/stats'
| '/edit/$id'
| '/entry/$id'
fileRoutesByTo: FileRoutesByTo
to:
| "/login"
| "/register"
| "/activities"
| "/add"
| "/calendar"
| "/diary"
| "/export"
| "/import"
| "/reminders"
| "/settings"
| "/stats"
| "/"
| "/edit/$id"
| "/entry/$id"
| '/login'
| '/register'
| '/activities'
| '/add'
| '/calendar'
| '/diary'
| '/export'
| '/import'
| '/metrics'
| '/reminders'
| '/settings'
| '/stats'
| '/'
| '/edit/$id'
| '/entry/$id'
id:
| "__root__"
| "/_auth"
| "/login"
| "/register"
| "/_auth/activities"
| "/_auth/add"
| "/_auth/calendar"
| "/_auth/diary"
| "/_auth/export"
| "/_auth/import"
| "/_auth/reminders"
| "/_auth/settings"
| "/_auth/stats"
| "/_auth/"
| "/_auth/edit/$id"
| "/_auth/entry/$id"
| '__root__'
| '/_auth'
| '/login'
| '/register'
| '/_auth/activities'
| '/_auth/add'
| '/_auth/calendar'
| '/_auth/diary'
| '/_auth/export'
| '/_auth/import'
| '/_auth/metrics'
| '/_auth/reminders'
| '/_auth/settings'
| '/_auth/stats'
| '/_auth/'
| '/_auth/edit/$id'
| '/_auth/entry/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -208,110 +220,117 @@ export interface RootRouteChildren {
RegisterRoute: typeof RegisterRoute
}
declare module "@tanstack/react-router" {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
"/_auth": {
id: "/_auth"
path: ""
fullPath: "/"
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
"/login": {
id: "/login"
path: "/login"
fullPath: "/login"
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
"/register": {
id: "/register"
path: "/register"
fullPath: "/register"
'/register': {
id: '/register'
path: '/register'
fullPath: '/register'
preLoaderRoute: typeof RegisterRouteImport
parentRoute: typeof rootRouteImport
}
"/_auth/": {
id: "/_auth/"
path: "/"
fullPath: "/"
'/_auth/': {
id: '/_auth/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AuthIndexRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/activities": {
id: "/_auth/activities"
path: "/activities"
fullPath: "/activities"
'/_auth/activities': {
id: '/_auth/activities'
path: '/activities'
fullPath: '/activities'
preLoaderRoute: typeof AuthActivitiesRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/add": {
id: "/_auth/add"
path: "/add"
fullPath: "/add"
'/_auth/add': {
id: '/_auth/add'
path: '/add'
fullPath: '/add'
preLoaderRoute: typeof AuthAddRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/calendar": {
id: "/_auth/calendar"
path: "/calendar"
fullPath: "/calendar"
'/_auth/calendar': {
id: '/_auth/calendar'
path: '/calendar'
fullPath: '/calendar'
preLoaderRoute: typeof AuthCalendarRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/diary": {
id: "/_auth/diary"
path: "/diary"
fullPath: "/diary"
'/_auth/diary': {
id: '/_auth/diary'
path: '/diary'
fullPath: '/diary'
preLoaderRoute: typeof AuthDiaryRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/export": {
id: "/_auth/export"
path: "/export"
fullPath: "/export"
'/_auth/export': {
id: '/_auth/export'
path: '/export'
fullPath: '/export'
preLoaderRoute: typeof AuthExportRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/import": {
id: "/_auth/import"
path: "/import"
fullPath: "/import"
'/_auth/import': {
id: '/_auth/import'
path: '/import'
fullPath: '/import'
preLoaderRoute: typeof AuthImportRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/reminders": {
id: "/_auth/reminders"
path: "/reminders"
fullPath: "/reminders"
'/_auth/metrics': {
id: '/_auth/metrics'
path: '/metrics'
fullPath: '/metrics'
preLoaderRoute: typeof AuthMetricsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/reminders': {
id: '/_auth/reminders'
path: '/reminders'
fullPath: '/reminders'
preLoaderRoute: typeof AuthRemindersRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/settings": {
id: "/_auth/settings"
path: "/settings"
fullPath: "/settings"
'/_auth/settings': {
id: '/_auth/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AuthSettingsRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/stats": {
id: "/_auth/stats"
path: "/stats"
fullPath: "/stats"
'/_auth/stats': {
id: '/_auth/stats'
path: '/stats'
fullPath: '/stats'
preLoaderRoute: typeof AuthStatsRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/edit/$id": {
id: "/_auth/edit/$id"
path: "/edit/$id"
fullPath: "/edit/$id"
'/_auth/edit/$id': {
id: '/_auth/edit/$id'
path: '/edit/$id'
fullPath: '/edit/$id'
preLoaderRoute: typeof AuthEditIdRouteImport
parentRoute: typeof AuthRoute
}
"/_auth/entry/$id": {
id: "/_auth/entry/$id"
path: "/entry/$id"
fullPath: "/entry/$id"
'/_auth/entry/$id': {
id: '/_auth/entry/$id'
path: '/entry/$id'
fullPath: '/entry/$id'
preLoaderRoute: typeof AuthEntryIdRouteImport
parentRoute: typeof AuthRoute
}
@@ -325,6 +344,7 @@ interface AuthRouteChildren {
AuthDiaryRoute: typeof AuthDiaryRoute
AuthExportRoute: typeof AuthExportRoute
AuthImportRoute: typeof AuthImportRoute
AuthMetricsRoute: typeof AuthMetricsRoute
AuthRemindersRoute: typeof AuthRemindersRoute
AuthSettingsRoute: typeof AuthSettingsRoute
AuthStatsRoute: typeof AuthStatsRoute
@@ -340,6 +360,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthDiaryRoute: AuthDiaryRoute,
AuthExportRoute: AuthExportRoute,
AuthImportRoute: AuthImportRoute,
AuthMetricsRoute: AuthMetricsRoute,
AuthRemindersRoute: AuthRemindersRoute,
AuthSettingsRoute: AuthSettingsRoute,
AuthStatsRoute: AuthStatsRoute,

View File

@@ -1,6 +1,7 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
import { AppShell } from "@/components/layout/app-shell"
import { Skeleton } from "@/components/ui/skeleton"
import { useEnsureTimezone } from "@/hooks/use-timezone"
export const Route = createFileRoute("/_auth")({
beforeLoad: () => {
@@ -22,6 +23,8 @@ export const Route = createFileRoute("/_auth")({
})
function AuthLayout() {
useEnsureTimezone()
return (
<AppShell>
<Outlet />

View File

@@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { EntryForm, type EntryFormData } from "@/components/entry/entry-form"
import { EntryForm } from "@/components/entry/entry-form"
import type { EntryFormData } from "@/components/entry/entry-form"
import { useCreateEntry } from "@/hooks/use-entries"
export const Route = createFileRoute("/_auth/add")({ component: AddEntryPage })

View File

@@ -35,10 +35,11 @@ function CalendarPage() {
: false
const canGoForward = isBefore(startOfMonth(month), startOfMonth(new Date()))
const selectedEntries = selectedDate
? (days?.find((d) => isSameDay(new Date(d.date), selectedDate))?.entries ??
[])
: []
const selectedDay = selectedDate
? days?.find((d) => isSameDay(new Date(d.date), selectedDate))
: undefined
const selectedEntries = selectedDay?.entries ?? []
const selectedCycleDay = selectedDay?.cycleDay ?? null
return (
<div className="flex flex-col gap-4 py-6">
@@ -75,8 +76,13 @@ function CalendarPage() {
{selectedDate && (
<section>
<h2 className="mb-2 text-sm font-medium text-muted-foreground">
<h2 className="mb-2 flex items-baseline gap-2 text-sm font-medium text-muted-foreground">
{format(selectedDate, "EEEE, MMMM d")}
{selectedCycleDay !== null && (
<span className="text-xs font-normal">
cycle day {selectedCycleDay}
</span>
)}
</h2>
{selectedEntries.length > 0 ? (
<div className="flex flex-col gap-2">
@@ -117,8 +123,8 @@ function MoodCalendarGrid({
const moodMap = new Map<string, number>()
for (const day of days) {
if (day.dominantMood) {
moodMap.set(day.date, day.dominantMood)
if (day.mood) {
moodMap.set(day.date, day.mood)
}
}

View File

@@ -1,8 +1,17 @@
import {
activitiesOf,
contentOf,
locationOf,
photosOf,
songOf,
voiceMemosOf,
} from "@/lib/dimensions"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { EntryForm, type EntryFormData } from "@/components/entry/entry-form"
import { EntryForm } from "@/components/entry/entry-form"
import type { EntryFormData } from "@/components/entry/entry-form"
import { useEntry } from "@/hooks/use-entries"
import { useUpdateEntry } from "@/hooks/use-update-entry"
@@ -62,10 +71,13 @@ function EditEntryPage() {
onSubmit={handleSubmit}
initial={{
mood: entry.mood,
content: entry.content ?? undefined,
activities: entry.activities,
photos: entry.photos,
voiceMemos: entry.voiceMemos,
loggedAt: entry.loggedAt,
content: contentOf(entry) ?? undefined,
activities: activitiesOf(entry),
photos: photosOf(entry),
voiceMemos: voiceMemosOf(entry),
location: locationOf(entry),
song: songOf(entry),
}}
/>
</div>

View File

@@ -1,8 +1,17 @@
import {
activitiesOf,
contentOf,
locationOf,
photosOf,
songOf,
voiceMemosOf,
} from "@/lib/dimensions"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { format } from "date-fns"
import { ArrowLeft, Pencil, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { EntryWeather } from "@/components/entry/entry-weather"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import {
@@ -113,38 +122,70 @@ function EntryDetailPage() {
</CardContent>
</Card>
{entry.content && (
{contentOf(entry) && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Note</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{entry.content}</p>
<p className="text-sm whitespace-pre-wrap">{contentOf(entry)}</p>
</CardContent>
</Card>
)}
{entry.activities.length > 0 && (
{songOf(entry) && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Listening to</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">
{songOf(entry)?.title}
<span className="text-muted-foreground">
{" "}
{songOf(entry)?.artist}
</span>
</p>
</CardContent>
</Card>
)}
{locationOf(entry) && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Location</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1.5">
<p className="text-sm text-muted-foreground">
{locationOf(entry)?.latitude.toFixed(4)},{" "}
{locationOf(entry)?.longitude.toFixed(4)}
</p>
<EntryWeather entry={entry} />
</CardContent>
</Card>
)}
{activitiesOf(entry).length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Activities</CardTitle>
</CardHeader>
<CardContent>
<GroupedActivityBadges
activityIds={entry.activities}
activityIds={activitiesOf(entry)}
allActivities={activities ?? []}
/>
</CardContent>
</Card>
)}
{entry.photos.length > 0 && (
{photosOf(entry).length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Photos</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2">
{entry.photos.map((photoId) => (
{photosOf(entry).map((photoId) => (
<img
key={photoId}
src={media.photoUrl(photoId)}
@@ -156,13 +197,13 @@ function EntryDetailPage() {
</Card>
)}
{entry.voiceMemos.length > 0 && (
{voiceMemosOf(entry).length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Voice memos</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{entry.voiceMemos.map((memoId) => (
{voiceMemosOf(entry).map((memoId) => (
<AudioPlayer key={memoId} src={media.voiceMemoUrl(memoId)} />
))}
</CardContent>

View File

@@ -1,63 +1,100 @@
import { createFileRoute } from "@tanstack/react-router"
import { AlertTriangle, Download, Share2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useExport } from "@/hooks/use-import-export"
import { Download, FileArchive } from "lucide-react"
import {
useCompleteBackup,
useShareableExtract,
} from "@/hooks/use-import-export"
export const Route = createFileRoute("/_auth/export")({
component: ExportPage,
})
function ExportPage() {
const exportData = useExport()
const backup = useCompleteBackup()
const extract = useShareableExtract()
return (
<div className="flex flex-col gap-6 py-6">
<h1 className="text-xl font-bold">Export data</h1>
<h1 className="text-xl font-bold">Take your data out</h1>
<Card>
<CardHeader>
<CardTitle className="text-sm">What's included</CardTitle>
<p className="text-sm text-muted-foreground">
Two different things, for two different reasons. One is for keeping, the
other is for handing to someone.
</p>
<Card className="border-destructive/40">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4 text-destructive" />
Complete backup keep this private
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm text-muted-foreground">
<p>Your export will contain:</p>
<ul className="list-inside list-disc space-y-1">
<li>All mood entries with notes</li>
<li>Your activity catalog</li>
<li>Photos and voice memos</li>
<li>Reminder schedules</li>
</ul>
<p className="mt-2">
The export is a ZIP file in k-mood's native format. You can
re-import it into any k-mood instance.
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Everything this account knows about you, so that nothing is lost if
you need to restore it.
</p>
<ul className="list-inside list-disc space-y-0.5 text-xs text-muted-foreground">
<li>Every entry, with its notes, photos and voice memos</li>
<li>Where you were and what you were listening to</li>
<li>Steps, sleep, heart rate and every other daily metric</li>
<li>Cycle records, if you track them</li>
<li>Your activity catalogue, reminders and settings</li>
</ul>
<p className="text-xs text-destructive">
Do not send this to anyone. It is a copy of your whole journal.
</p>
<Button
size="lg"
variant="outline"
className="w-full"
disabled={backup.isPending}
onClick={() => backup.mutate()}
>
<Download className="mr-2 h-4 w-4" />
{backup.isPending
? "Preparing backup…"
: "Download complete backup (.zip)"}
</Button>
</CardContent>
</Card>
<Button
size="lg"
onClick={() => exportData.mutate()}
disabled={exportData.isPending}
className="w-full"
>
{exportData.isPending ? (
<>Preparing export...</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Download export
</>
)}
</Button>
{exportData.isSuccess && (
<Card>
<CardContent className="flex items-center gap-3 py-4">
<FileArchive className="h-5 w-5 text-green-500" />
<span className="text-sm">Export downloaded successfully</span>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Share2 className="h-4 w-4" />
Shareable journal safe to send
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
A readable document of how you have been feeling, for a therapist, a
doctor, or someone you trust.
</p>
<ul className="list-inside list-disc space-y-0.5 text-xs text-muted-foreground">
<li>Your mood, day by day</li>
<li>What you wrote</li>
<li>What you tagged</li>
</ul>
<p className="text-xs text-muted-foreground">
It carries no places, no health readings, no cycle records and no
photos. It cannot be used to restore your account.
</p>
<Button
size="lg"
className="w-full"
disabled={extract.isPending}
onClick={() => extract.mutate()}
>
<Share2 className="mr-2 h-4 w-4" />
{extract.isPending
? "Preparing journal…"
: "Download shareable journal (.md)"}
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -2,11 +2,11 @@ import { useState, useRef } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useImport } from "@/hooks/use-import-export"
import { useImport, useRestore } from "@/hooks/use-import-export"
import { ImportWizard } from "@/components/import/import-wizard"
import { WizardResult } from "@/components/import/wizard-result"
import { Upload, FileUp } from "lucide-react"
import type { ImportResultResponse } from "@/api/schema"
import type { ImportResultResponse, RestoreOutcomeResponse } from "@/api/schema"
export const Route = createFileRoute("/_auth/import")({
component: ImportPage,
@@ -18,22 +18,30 @@ function ImportPage() {
const [source, setSource] = useState<ImportSource | null>(null)
const [file, setFile] = useState<File | null>(null)
const [result, setResult] = useState<ImportResultResponse | null>(null)
const [restored, setRestored] = useState<RestoreOutcomeResponse | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const importData = useImport()
const restoreBackup = useRestore()
const working = importData.isPending || restoreBackup.isPending
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0]
if (selected) {
setFile(selected)
setResult(null)
setRestored(null)
}
}
const handleImport = () => {
if (!file) return
importData.mutate(file, {
onSuccess: (data) => setResult(data),
})
if (source === "kmood") {
restoreBackup.mutate(file, { onSuccess: setRestored })
return
}
importData.mutate(file, { onSuccess: setResult })
}
return (
@@ -53,7 +61,7 @@ function ImportPage() {
/>
<SourceOption
label="k-mood backup"
description="Restore from a k-mood ZIP export"
description="Restore a complete backup, with everything in it"
onClick={() => setSource("kmood")}
/>
<SourceOption
@@ -65,7 +73,7 @@ function ImportPage() {
</Card>
)}
{(source === "daylio" || source === "kmood") && !result && (
{(source === "daylio" || source === "kmood") && !result && !restored && (
<Card>
<CardHeader>
<CardTitle className="text-sm">
@@ -77,8 +85,8 @@ function ImportPage() {
<CardContent className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
{source === "daylio"
? "Export your data from Daylio (Settings → Export → CSV) and upload the file here."
: "Upload a previously exported k-mood ZIP file to restore your data."}
? "Export your data from Daylio (Settings → Export → CSV) and upload the file here. Daylio records the time you logged but not which timezone you were in, so times are read as your own — 8 PM in the file becomes 8 PM where you live. Set your timezone in settings first."
: "Upload a complete backup to restore it. Everything in it comes back: entries with every dimension, daily metrics, cycle records, activities, reminders and settings. A restore adds to this account rather than replacing it."}
</p>
<input
@@ -102,15 +110,19 @@ function ImportPage() {
<Button
size="lg"
onClick={handleImport}
disabled={importData.isPending}
disabled={working}
className="w-full"
>
{importData.isPending ? (
"Importing..."
{working ? (
source === "kmood" ? (
"Restoring..."
) : (
"Importing..."
)
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Import
{source === "kmood" ? "Restore" : "Import"}
</>
)}
</Button>
@@ -123,7 +135,9 @@ function ImportPage() {
{result && <WizardResult result={result} />}
{source && !result && (
{restored && <RestoreSummary outcome={restored} />}
{source && !result && !restored && (
<Button
variant="ghost"
onClick={() => {
@@ -158,3 +172,43 @@ function SourceOption({
</Button>
)
}
function RestoreSummary({ outcome }: { outcome: RestoreOutcomeResponse }) {
const counts = [
["Entries", outcome.entries],
["Daily metrics", outcome.metrics],
["Cycle records", outcome.cycleStarts],
["Activities", outcome.activities],
["Reminders", outcome.reminders],
["Photos and voice memos", outcome.media],
] as const
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Restored</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{counts.map(([label, count]) => (
<div key={label} className="flex justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{count}</span>
</div>
))}
{outcome.unreadable.length > 0 && (
<div className="mt-2 flex flex-col gap-1 border-l-2 border-destructive/40 pl-2">
<span className="text-xs font-medium">
Could not be read, and was not restored:
</span>
{outcome.unreadable.map((reason) => (
<span key={reason} className="text-[10px] text-muted-foreground">
{reason}
</span>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,9 +1,11 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { format } from "date-fns"
import { Card, CardContent } from "@/components/ui/card"
import { Footprints } from "lucide-react"
import { MoodBadge } from "@/components/mood/mood-badge"
import { EntryList } from "@/components/entry/entry-list"
import { useEntries, useMoodStats } from "@/hooks/use-entries"
import { byKind, useDailyMetrics } from "@/hooks/use-metrics"
import { useAuth } from "@/hooks/use-auth"
import { Skeleton } from "@/components/ui/skeleton"
@@ -15,6 +17,9 @@ function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useMoodStats()
const today = format(new Date(), "EEEE, MMMM d")
const todaysDate = format(new Date(), "yyyy-MM-dd")
const { data: todaysMetrics } = useDailyMetrics(todaysDate, todaysDate)
const steps = byKind(todaysMetrics).get("steps")
const latestMood = entries?.[0]?.mood
return (
@@ -35,6 +40,20 @@ function DashboardPage() {
</Card>
)}
<Link to="/metrics">
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
<Footprints className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Steps today</span>
</div>
<span className="text-sm font-semibold">
{steps ? steps.value.toLocaleString() : "Add"}
</span>
</CardContent>
</Card>
</Link>
{statsLoading ? (
<Skeleton className="h-24" />
) : stats ? (

View File

@@ -0,0 +1,156 @@
import { useEffect, useState } from "react"
import { createFileRoute, Link } from "@tanstack/react-router"
import { format } from "date-fns"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { METRIC_FIELDS } from "@/components/metric/metric-fields"
import { MetricRow } from "@/components/metric/metric-row"
import {
byKind,
useDailyMetrics,
useSetDailyMetrics,
} from "@/hooks/use-metrics"
import type { MetricKind, MetricPayload } from "@/api/schema"
export const Route = createFileRoute("/_auth/metrics")({
component: MetricsPage,
})
type Drafts = Record<string, string>
function storedDrafts(stored: Map<MetricKind, { value: number }>): Drafts {
return Object.fromEntries(
METRIC_FIELDS.map((field) => [
field.kind,
stored.has(field.kind) ? String(stored.get(field.kind)?.value) : "",
])
)
}
function saveLabel(changes: number, cleared: number): string {
if (changes === 0) return "Nothing to save"
const plural = changes === 1 ? "" : "s"
if (cleared === 0) return `Save ${changes} change${plural}`
if (cleared === changes)
return `Clear ${cleared} reading${cleared === 1 ? "" : "s"}`
return `Save ${changes} change${plural}, ${cleared} cleared`
}
function MetricsPage() {
const today = format(new Date(), "yyyy-MM-dd")
const [date, setDate] = useState(today)
const { data: stored, isLoading } = useDailyMetrics(date, date)
const save = useSetDailyMetrics()
const recorded = byKind(stored)
const [drafts, setDrafts] = useState<Drafts>({})
const [syncedFor, setSyncedFor] = useState<string | null>(null)
useEffect(() => {
if (!stored || syncedFor === date) return
setDrafts(storedDrafts(byKind(stored)))
setSyncedFor(date)
}, [stored, date, syncedFor])
const edited = METRIC_FIELDS.filter((field) => {
const draft = drafts[field.kind] ?? ""
const current = recorded.get(field.kind)
return draft !== (current ? String(current.value) : "")
})
const invalid = edited.filter((field) => {
const draft = drafts[field.kind] ?? ""
if (draft === "") return false
const count = Number(draft)
return !Number.isInteger(count) || count < field.min || count > field.max
})
const changes: MetricPayload[] = edited
.filter((field) => !invalid.includes(field))
.map((field) => ({
kind: field.kind,
value: drafts[field.kind] === "" ? null : Number(drafts[field.kind]),
}))
const cleared = changes.filter((change) => change.value === null).length
return (
<div className="flex flex-col gap-6 py-6">
<header className="flex items-center gap-2">
<Link to="/">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<h1 className="text-2xl font-bold">Daily metrics</h1>
</header>
<div className="flex flex-col gap-1.5">
<Label className="text-xs">Day</Label>
<Input
type="date"
value={date}
max={today}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<Card>
<CardContent className="flex flex-col gap-4 py-4">
{isLoading ? (
<>
<Skeleton className="h-14" />
<Skeleton className="h-14" />
<Skeleton className="h-14" />
</>
) : (
METRIC_FIELDS.map((field) => (
<MetricRow
key={field.kind}
field={field}
stored={recorded.get(field.kind)}
draft={drafts[field.kind] ?? ""}
onChange={(draft) =>
setDrafts((current) => ({ ...current, [field.kind]: draft }))
}
/>
))
)}
</CardContent>
</Card>
{invalid.length > 0 && (
<p className="text-xs text-destructive">
{invalid.map((field) => field.label).join(", ")}:{" "}
{invalid.length === 1 ? "value is" : "values are"} outside the allowed
range.
</p>
)}
<Button
disabled={changes.length === 0 || save.isPending}
onClick={() =>
save.mutate(
{ date, values: changes },
{ onSuccess: () => setSyncedFor(null) }
)
}
>
{save.isPending ? "Saving…" : saveLabel(changes.length, cleared)}
</Button>
{save.isError && (
<p className="text-xs text-destructive">
{save.error instanceof Error ? save.error.message : "Could not save"}
</p>
)}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { createFileRoute, Link } from "@tanstack/react-router"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Switch } from "@/components/ui/switch"
@@ -22,7 +22,6 @@ import {
} from "@/hooks/use-reminders"
import type { ReminderResponse, CreateReminderRequest } from "@/api/schema"
import { Plus, Trash2, ArrowLeft } from "lucide-react"
import { Link } from "@tanstack/react-router"
export const Route = createFileRoute("/_auth/reminders")({
component: RemindersPage,
@@ -293,7 +292,7 @@ function ReminderForm({
{schedule[key] && (
<input
type="time"
value={schedule[key]!}
value={schedule[key]}
onChange={(e) => setTime(key, e.target.value)}
className="h-9 rounded-md border bg-transparent px-3 text-sm"
/>

View File

@@ -3,6 +3,10 @@ import { createFileRoute, Link } from "@tanstack/react-router"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { SubsonicCard } from "@/components/settings/subsonic-card"
import { ApiTokensCard } from "@/components/settings/api-tokens-card"
import { RejectionsCard } from "@/components/settings/rejections-card"
import { CycleCard } from "@/components/settings/cycle-card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
@@ -80,6 +84,14 @@ function SettingsPage() {
timezone={user?.timezone ?? null}
/>
<SubsonicCard />
<CycleCard />
<ApiTokensCard />
<RejectionsCard />
<Card>
<CardHeader>
<CardTitle className="text-sm">Personalization</CardTitle>

View File

@@ -8,7 +8,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { MoodTrendChart } from "@/components/stats/mood-trend-chart"
import { MoodDistribution } from "@/components/stats/mood-distribution"
import { MoodHeatmap } from "@/components/stats/mood-heatmap"
import { ActivityCorrelations } from "@/components/stats/activity-correlations"
import { MoodCorrelations } from "@/components/stats/mood-correlations"
import { StatCard } from "@/components/stats/stat-card"
import { useMoodStats, usePeriodEntries } from "@/hooks/use-stats"
import { useYearCalendar } from "@/hooks/use-year-calendar"
@@ -57,18 +57,18 @@ function StatsPage() {
}
/>
<StatCard label="Streak" value={`${stats.currentStreak}d`} />
<StatCard label="Entries" value={stats.totalEntries ?? 0} />
<StatCard label="Entries" value={stats.totalEntries} />
</div>
{trendEntries?.length ? (
<MoodTrendChart entries={trendEntries} period={period} />
) : null}
{stats.frequency?.length ? (
{stats.frequency.length ? (
<MoodDistribution frequency={stats.frequency} />
) : null}
<ActivityCorrelations />
<MoodCorrelations />
</>
) : null}