@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
41
spa/src/components/entry/entry-weather.tsx
Normal file
41
spa/src/components/entry/entry-weather.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
spa/src/components/entry/logged-at-picker.tsx
Normal file
63
spa/src/components/entry/logged-at-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
88
spa/src/components/metric/metric-fields.ts
Normal file
88
spa/src/components/metric/metric-fields.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
46
spa/src/components/metric/metric-row.tsx
Normal file
46
spa/src/components/metric/metric-row.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
137
spa/src/components/settings/api-tokens-card.tsx
Normal file
137
spa/src/components/settings/api-tokens-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
spa/src/components/settings/cycle-card.tsx
Normal file
101
spa/src/components/settings/cycle-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
spa/src/components/settings/rejections-card.tsx
Normal file
68
spa/src/components/settings/rejections-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
spa/src/components/settings/subsonic-card.tsx
Normal file
94
spa/src/components/settings/subsonic-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
148
spa/src/components/stats/mood-correlations.tsx
Normal file
148
spa/src/components/stats/mood-correlations.tsx
Normal 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`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
29
spa/src/hooks/use-api-tokens.ts
Normal file
29
spa/src/hooks/use-api-tokens.ts
Normal 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 }),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
57
spa/src/hooks/use-cycle.ts
Normal file
57
spa/src/hooks/use-cycle.ts
Normal 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"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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] })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
32
spa/src/hooks/use-metrics.ts
Normal file
32
spa/src/hooks/use-metrics.ts
Normal 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]) ?? [])
|
||||
}
|
||||
15
spa/src/hooks/use-mood-correlations.ts
Normal file
15
spa/src/hooks/use-mood-correlations.ts
Normal 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 }),
|
||||
})
|
||||
}
|
||||
37
spa/src/hooks/use-providers.ts
Normal file
37
spa/src/hooks/use-providers.ts
Normal 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(),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
9
spa/src/hooks/use-rejections.ts
Normal file
9
spa/src/hooks/use-rejections.ts
Normal 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(),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
33
spa/src/hooks/use-timezone.ts
Normal file
33
spa/src/hooks/use-timezone.ts
Normal 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
72
spa/src/lib/dimensions.ts
Normal 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
31
spa/src/lib/logged-at.ts
Normal 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
|
||||
}
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
156
spa/src/routes/_auth/metrics.tsx
Normal file
156
spa/src/routes/_auth/metrics.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user