Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers #125
@@ -134,7 +134,10 @@ Conventions that follow from it:
|
|||||||
a view, `cm` changes only when the person types. Never re-parse the display
|
a view, `cm` changes only when the person types. Never re-parse the display
|
||||||
string on save — "29′ 6.3″" is the nearest tenth of an inch, and parsing it
|
string on save — "29′ 6.3″" is the nearest tenth of an inch, and parsing it
|
||||||
back is how a no-change Save turned 900 cm into 899.922 (and bumped the
|
back is how a no-change Save turned 900 cm into 899.922 (and bumped the
|
||||||
version, and wrote a bogus history entry). The inspector guards the same way.
|
version, and wrote a bogus history entry). The inspector still keeps display
|
||||||
|
strings but gets the same result by refusing to commit text that still equals
|
||||||
|
the formatted original (`commitDim`); either way, a no-op save sends exactly
|
||||||
|
what was loaded — or nothing.
|
||||||
- **"Today" is the browser's local day**, from `today()` in
|
- **"Today" is the browser's local day**, from `today()` in
|
||||||
`web/src/lib/dates.ts`, and the UI always sends it: journal `observedAt`,
|
`web/src/lib/dates.ts`, and the UI always sends it: journal `observedAt`,
|
||||||
plop/fill `plantedAt`, `removedAt`. The server's UTC default is only for
|
plop/fill `plantedAt`, `removedAt`. The server's UTC default is only for
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@@ -27,7 +27,13 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () =>
|
|||||||
const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
|
const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
|
||||||
const year = nextPlanYear(base, names, from)
|
const year = nextPlanYear(base, names, from)
|
||||||
const [name, setName] = useState(() => planNameFor(base, year))
|
const [name, setName] = useState(() => planNameFor(base, year))
|
||||||
|
const [touched, setTouched] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// The gardens list can still be loading when this opens; until the person
|
||||||
|
// edits the name, keep the proposal in step with what the list says is free.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!touched) setName(planNameFor(base, year))
|
||||||
|
}, [base, year, touched])
|
||||||
// The API allows duplicate names; say so rather than let two gardens read as
|
// The API allows duplicate names; say so rather than let two gardens read as
|
||||||
// the same season's plan.
|
// the same season's plan.
|
||||||
const taken = names.some((n) => n.trim() === name.trim())
|
const taken = names.some((n) => n.trim() === name.trim())
|
||||||
@@ -52,7 +58,17 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () =>
|
|||||||
A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in — rearrange freely, the
|
A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in — rearrange freely, the
|
||||||
original stays put. Beds and what's planted come along; shares and the public link don't.
|
original stays put. Beds and what's planted come along; shares and the public link don't.
|
||||||
</p>
|
</p>
|
||||||
<TextField label="Name" name="name" required autoFocus value={name} onChange={(e) => setName(e.target.value)} />
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTouched(true)
|
||||||
|
setName(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<p className="text-xs text-ink-mute">Keep the “— {year}” and it shows up as that season's plan in the editor.</p>
|
<p className="text-xs text-ink-mute">Keep the “— {year}” and it shows up as that season's plan in the editor.</p>
|
||||||
{taken && <Alert tone="info">You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.</Alert>}
|
{taken && <Alert tone="info">You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.</Alert>}
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import { GardenThumb } from './GardenThumb'
|
|||||||
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
|
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One garden as a card: the plot thumbnail (a link into the editor), name (a
|
* One garden as a card: the plot thumbnail (a link into the editor), name, size,
|
||||||
|
|
|||||||
* plan copy shows its base name and a `<year> plan` tag), size, a counts line, who it's shared with, and a footer
|
* a counts line, who it's shared with, and a footer of Open + share / copy /
|
||||||
* of Open + share / copy / edit / delete. A garden shared WITH you shows its
|
* edit / delete. A plan copy shows its base name with a `<year> plan` tag. A
|
||||||
* role and a leave action instead of the owner's tools.
|
* garden shared WITH you shows its role and a leave action instead of the
|
||||||
|
* owner's tools.
|
||||||
*/
|
*/
|
||||||
export function GardenCard({
|
export function GardenCard({
|
||||||
garden,
|
garden,
|
||||||
@@ -40,10 +41,11 @@ export function GardenCard({
|
|||||||
const owner = currentUserId != null && garden.ownerId === currentUserId
|
const owner = currentUserId != null && garden.ownerId === currentUserId
|
||||||
const full = useGardenFull(garden.id)
|
const full = useGardenFull(garden.id)
|
||||||
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
|
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
|
||||||
|
const plan = parsePlanName(garden.name)
|
||||||
const planYear = planYearOf(garden.name)
|
const planYear = planYearOf(garden.name)
|
||||||
// A plan's year is the point of its name, and the first thing truncation
|
// A plan's year is the point of its name, and the first thing truncation
|
||||||
|
gitea-actions
commented
⚪ Dead optional-chain and nullish-fallback on parsePlanName when planYear is already non-null maintainability · flagged by 1 model
|
|||||||
// would eat ("Back Yard — 20…"); show the base name and put the year on the tag.
|
// would eat ("Back Yard — 20…"); show the base name and put the year on the tag.
|
||||||
const title = planYear != null ? (parsePlanName(garden.name)?.base ?? garden.name) : garden.name
|
const title = planYear != null && plan ? plan.base : garden.name
|
||||||
|
|
||||||
const meta = useMemo(() => {
|
const meta = useMemo(() => {
|
||||||
const data = full.data
|
const data = full.data
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { localToWorld } from '@/lib/geometry'
|
import { localToWorld } from '@/lib/geometry'
|
||||||
import type { FullGarden } from '@/lib/objects'
|
import type { FullGarden } from '@/lib/objects'
|
||||||
|
import { FALLBACK_PLANT_COLOR } from '@/lib/plants'
|
||||||
import { objectStyle, rectRadius } from '@/editor/kinds'
|
import { objectStyle, rectRadius } from '@/editor/kinds'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,7 +73,7 @@ export function GardenThumb({
|
|||||||
const o = byId.get(p.objectId)
|
const o = byId.get(p.objectId)
|
||||||
if (!o) return null
|
if (!o) return null
|
||||||
const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg)
|
const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg)
|
||||||
return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? '#97a97c'} />
|
return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? FALLBACK_PLANT_COLOR} />
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { clampScale, type Point } from '@/lib/geometry'
|
import { clampScale, type Point } from '@/lib/geometry'
|
||||||
import { monogramInk } from '@/lib/monogram'
|
import { monogramInk } from '@/lib/monogram'
|
||||||
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
||||||
import type { Plant } from '@/lib/plants'
|
import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants'
|
||||||
import type { EditorPlanting } from '@/lib/plantings'
|
import type { EditorPlanting } from '@/lib/plantings'
|
||||||
import { formatSize } from '@/lib/units'
|
import { formatSize } from '@/lib/units'
|
||||||
import { kindDef, objectStyle, rectRadius } from './kinds'
|
import { kindDef, objectStyle, rectRadius } from './kinds'
|
||||||
@@ -35,10 +35,6 @@ import {
|
|||||||
import { useEditorStore, type Viewport } from './store'
|
import { useEditorStore, type Viewport } from './store'
|
||||||
import type { EditorGarden, EditorObject } from './types'
|
import type { EditorGarden, EditorObject } from './types'
|
||||||
|
|
||||||
// A plop whose plant is missing from the catalog (a shared garden's private
|
|
||||||
// plant) still needs a color to be drawn in.
|
|
||||||
const FALLBACK_PLANT_COLOR = '#97a97c'
|
|
||||||
|
|
||||||
const WHEEL_SENSITIVITY = 0.0016
|
const WHEEL_SENSITIVITY = 0.0016
|
||||||
const ANIM_MS = 520
|
const ANIM_MS = 520
|
||||||
const REFIT_THRESHOLD_PX = 60
|
const REFIT_THRESHOLD_PX = 60
|
||||||
|
gitea-actions
commented
🟡 Fallback plant color #97a97c named in Canvas but left raw in Inspector.tsx:359 and GardenThumb.tsx:75; extract one shared constant maintainability · flagged by 1 model
🪰 Gadfly · advisory 🟡 **Fallback plant color #97a97c named in Canvas but left raw in Inspector.tsx:359 and GardenThumb.tsx:75; extract one shared constant**
_maintainability · flagged by 1 model_
- **`web/src/editor/Canvas.tsx:40` — half-finished extraction of the fallback plant color.** This PR names the unknown-plant fallback `FALLBACK_PLANT_COLOR = '#97a97c'` in Canvas (correctly reusing it for both the circle fill and `monogramInk`), but the identical literal stays raw at `Inspector.tsx:359` (`ColorDot color={plant?.color ?? '#97a97c'}`) and `GardenThumb.tsx:75` (`fill={plantColor.get(...) ?? '#97a97c'}`) — the same "plant missing from the catalog" default. The constant is module-loc…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Tag } from '@/components/ui/Tag'
|
|||||||
import { Toggle } from '@/components/ui/Toggle'
|
import { Toggle } from '@/components/ui/Toggle'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import { useDeleteObject, useRemovePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
import { useDeleteObject, useRemovePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
||||||
import type { Plant } from '@/lib/plants'
|
import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants'
|
||||||
import type { EditorPlanting } from '@/lib/plantings'
|
import type { EditorPlanting } from '@/lib/plantings'
|
||||||
import {
|
import {
|
||||||
cmFromSpacing,
|
cmFromSpacing,
|
||||||
@@ -356,7 +356,7 @@ export function PlopInspector({
|
|||||||
return (
|
return (
|
||||||
<div ref={rootRef} className="flex flex-col gap-3">
|
<div ref={rootRef} className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<ColorDot color={plant?.color ?? '#97a97c'} size={18} />
|
<ColorDot color={plant?.color ?? FALLBACK_PLANT_COLOR} size={18} />
|
||||||
<span className="min-w-0 truncate font-heading text-[17px]">{plant?.name ?? 'Unknown plant'}</span>
|
<span className="min-w-0 truncate font-heading text-[17px]">{plant?.name ?? 'Unknown plant'}</span>
|
||||||
{noteCount > 0 && (
|
{noteCount > 0 && (
|
||||||
<button type="button" className="tag tag-accent-2 ml-auto cursor-pointer border-0" onClick={onNotes}>
|
<button type="button" className="tag tag-accent-2 ml-auto cursor-pointer border-0" onClick={onNotes}>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { Button, IconButton } from '@/components/ui/Button'
|
|||||||
import { TextAreaField, TextField } from '@/components/ui/Field'
|
import { TextAreaField, TextField } from '@/components/ui/Field'
|
||||||
import { errorMessage } from '@/lib/api'
|
import { errorMessage } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
import { today } from '@/lib/dates'
|
||||||
import {
|
import {
|
||||||
formatObservedAt,
|
formatObservedAt,
|
||||||
today,
|
|
||||||
useCreateJournalEntry,
|
useCreateJournalEntry,
|
||||||
useDeleteJournalEntry,
|
useDeleteJournalEntry,
|
||||||
useJournal,
|
useJournal,
|
||||||
|
|||||||
@@ -127,8 +127,6 @@ export function useDeleteJournalEntry(gardenId: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Today" for a note is the same local day everything else stamps (lib/dates.ts).
|
|
||||||
export { today } from './dates'
|
|
||||||
|
|
||||||
/** A date-only string as a short human label, without dragging the value
|
/** A date-only string as a short human label, without dragging the value
|
||||||
|
gitea-actions
commented
⚪ today re-exported from journal.ts only to satisfy JournalTab; objects.ts already imports from lib/dates — inconsistent indirection maintainability · flagged by 1 model
🪰 Gadfly · advisory ⚪ **today re-exported from journal.ts only to satisfy JournalTab; objects.ts already imports from lib/dates — inconsistent indirection**
_maintainability · flagged by 1 model_
- **`web/src/lib/journal.ts:131`** — `today` is re-exported (`export { today } from './dates'`) purely so `JournalTab.tsx:7-16` can keep importing it from `@/lib/journal`, while `objects.ts:10` already imports `today` directly from `@/lib/dates`. With only one consumer, this is a needless second source of the symbol; re-pointing JournalTab's import at `@/lib/dates` and dropping the re-export would be consistent with the rest of the codebase.
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
* through a Date (which would shift it by the timezone offset). */
|
* through a Date (which would shift it by the timezone offset). */
|
||||||
|
|||||||
+10
-1
@@ -87,8 +87,17 @@ function luminance(color: string): number | null {
|
|||||||
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
|
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memoized by color string: the canvas asks for every visible plop on every
|
||||||
|
// frame of a pan, and a catalog has a dozen distinct colors, not thousands.
|
||||||
|
const inkByColor = new Map<string, string>()
|
||||||
|
|
||||||
/** The CSS color the monogram letters take on a marker of `color`. */
|
/** The CSS color the monogram letters take on a marker of `color`. */
|
||||||
export function monogramInk(color: string): string {
|
export function monogramInk(color: string): string {
|
||||||
|
let ink = inkByColor.get(color)
|
||||||
|
if (ink === undefined) {
|
||||||
const l = luminance(color)
|
const l = luminance(color)
|
||||||
return l !== null && l > PAPER_MAX_LUMINANCE ? INK : PAPER
|
ink = l !== null && l > PAPER_MAX_LUMINANCE ? INK : PAPER
|
||||||
|
inkByColor.set(color, ink)
|
||||||
|
}
|
||||||
|
return ink
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { z } from 'zod'
|
|||||||
import { ApiError, api } from './api'
|
import { ApiError, api } from './api'
|
||||||
|
|
||||||
export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const
|
export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const
|
||||||
|
|
||||||
|
/** The marker color for a plop whose plant isn't in the catalog you can see (a
|
||||||
|
* shared garden's private plant): the sage the curated swatches start with. */
|
||||||
|
export const FALLBACK_PLANT_COLOR = '#97a97c'
|
||||||
export type PlantCategory = (typeof PLANT_CATEGORIES)[number]
|
export type PlantCategory = (typeof PLANT_CATEGORIES)[number]
|
||||||
|
|
||||||
export const CATEGORY_LABELS: Record<PlantCategory, string> = {
|
export const CATEGORY_LABELS: Record<PlantCategory, string> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user
⚪ JSDoc summary rewritten to wrap mid-thought, producing a 2-line run-on; cosmetic readability regression
maintainability · flagged by 1 model
web/src/components/gardens/GardenCard.tsx:18-21— The JSDoc summary was rewritten to wrap awkwardly mid-thought ("name (a / plan copy shows its base name and a<year> plantag), size, ..."), producing a 2-line run-on where the rest of the file uses terse one-line summaries. Cosmetic, but a readability regression in a file-level comment.🪰 Gadfly · advisory