Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions app/controllers/admin/projects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ def toggle_staff_pick

def restore
authorize @project
@project.undiscard
unless @project.undiscard
return redirect_to admin_project_path(@project), alert: "Couldn't restore project: #{@project.errors.full_messages.to_sentence}"
end

audit!("project.restored", target: @project)
redirect_to admin_project_path(@project), notice: "Project '#{@project.name}' has been restored."
end
Expand Down Expand Up @@ -882,7 +885,7 @@ def fine_guard_reason(project, params)
approved_hours: capped_override_hours(project, params[:override_hours]) || project.devlog_hours,
journal_only: project.devlog_mode.to_s != "git",
uses_ai: project.uses_ai,
has_lapse: project.devlogs.any? { |d| d.lapse_url.present? },
has_lapse: project.devlogs.any? { |d| d.lapse_urls.any? },
red_flags: project.red_flags || []
)
blocking = issues.select(&:blocking?)
Expand Down Expand Up @@ -1148,7 +1151,7 @@ def serialize_devlog(devlog)
content: devlog.content,
time_spent: devlog.time_spent,
time_hours: devlog.time_hours&.to_f,
lapse_url: devlog.lapse_url,
lapse_urls: devlog.lapse_urls,
created_at: devlog.created_at.strftime("%b %d, %Y"),
meets_requirements: devlog.meets_submission_requirements?,
content_lost: devlog.content_lost,
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/admin/reviews_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ def serialize_devlog(devlog)
content: devlog.content,
time_spent: devlog.time_spent,
time_hours: devlog.time_hours&.to_f,
lapse_url: devlog.lapse_url,
lapse_urls: devlog.lapse_urls,
created_at: devlog.created_at.strftime("%b %d, %Y"),
created_at_iso: devlog.created_at.iso8601,
user_id: devlog.user_id,
Expand Down
6 changes: 3 additions & 3 deletions app/controllers/devlogs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def show
content: @devlog.content,
time_spent: @devlog.time_spent,
time_hours: @devlog.time_hours&.to_f,
lapse_url: policy(@devlog).view_lapse_url? ? @devlog.lapse_url : nil,
lapse_urls: policy(@devlog).view_lapse_url? ? @devlog.lapse_urls : [],
created_at: @devlog.created_at.strftime("%B %d, %Y"),
user_id: @devlog.user_id,
user_display_name: @devlog.user.display_name,
Expand Down Expand Up @@ -130,9 +130,9 @@ def set_project

def devlog_params
if @project.devlog_mode == "git"
params.expect(devlog: [ :lapse_url ])
params.expect(devlog: [ lapse_urls: [] ])
else
attrs = params.expect(devlog: [ :title, :content, :time_spent, :lapse_url ])
attrs = params.expect(devlog: [ :title, :content, :time_spent, lapse_urls: [] ])
attrs[:time_spent] = normalize_time_spent(attrs[:time_spent])
attrs[:time_hours] = TimeSpentParser.parse(attrs[:time_spent])
attrs
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/orphaned_lapse_links_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def relink
devlog = @project.devlogs.find(params[:devlog_id])

ActiveRecord::Base.transaction do
devlog.update!(lapse_url: link.lapse_url)
devlog.update!(lapse_urls: devlog.lapse_urls + [ link.lapse_url ])
link.destroy!
end

Expand Down
6 changes: 4 additions & 2 deletions app/controllers/projects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,9 @@ def set_project
end

def project_params
params.expect(project: [ :name, :subtitle, :repo_link, :tier, :devlog_mode, :linked_project_id, :uses_ai, :ai_usage, tags: [], hackatime_projects: [] ])
fields = [ :name, :subtitle, :repo_link, :tier, :devlog_mode, :uses_ai, :ai_usage, tags: [], hackatime_projects: [] ]
fields.unshift(:linked_project_id) if action_name == "create"
params.expect(project: fields)
end

def parent_project_for_new
Expand Down Expand Up @@ -717,7 +719,7 @@ def serialize_devlog(devlog)
content: devlog.content,
time_spent: devlog.time_spent,
time_hours: devlog.time_hours&.to_f,
lapse_url: policy(devlog).view_lapse_url? ? devlog.lapse_url : nil,
lapse_urls: policy(devlog).view_lapse_url? ? devlog.lapse_urls : [],
created_at: devlog.created_at.strftime("%B %d, %Y"),
user_id: devlog.user_id,
user_display_name: devlog.user.display_name,
Expand Down
59 changes: 59 additions & 0 deletions app/javascript/components/LapseUrlsInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const MAX_LAPSE_URLS = 10

export default function LapseUrlsInput({
value,
onChange,
placeholder,
inputClassName,
}: {
value: string[]
onChange: (urls: string[]) => void
placeholder: string
inputClassName: string
}) {
const urls = value.length > 0 ? value : ['']

function update(index: number, url: string) {
onChange(urls.map((u, i) => (i === index ? url : u)))
}

function remove(index: number) {
onChange(urls.filter((_, i) => i !== index))
}

return (
<div className="space-y-2">
{urls.map((url, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="url"
value={url}
onChange={(e) => update(index, e.target.value)}
className={`flex-1 ${inputClassName}`}
placeholder={placeholder}
/>
{urls.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="text-stone-600 hover:text-red-400 transition-colors cursor-pointer"
aria-label="Remove lapse link"
>
<span className="material-symbols-outlined text-sm">close</span>
</button>
)}
</div>
))}
{urls.length < MAX_LAPSE_URLS && (
<button
type="button"
onClick={() => onChange([...urls, ''])}
className="text-stone-500 hover:text-[#ffb595] transition-colors cursor-pointer text-xs flex items-center gap-1"
>
<span className="material-symbols-outlined text-sm">add</span>
Add another link
</button>
)}
</div>
)
}
18 changes: 18 additions & 0 deletions app/javascript/components/TimeSpentHint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { formatHours, parseTimeSpent } from '@/lib/timeSpent'

export default function TimeSpentHint({ value }: { value: string }) {
if (!value.trim()) return null

const hours = parseTimeSpent(value)
if (hours === null) {
return (
<p className="text-xs text-amber-400 mt-1.5">Couldn&apos;t read this time — try something like 3h 30m or 3:30.</p>
)
}

return (
<p className="text-xs text-stone-500 mt-1.5">
Counted as <span className="text-[#ffb595]">{formatHours(hours)}</span> ({hours} {hours === 1 ? 'hour' : 'hours'})
</p>
)
}
14 changes: 7 additions & 7 deletions app/javascript/components/admin/review/ContentTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { Fragment, useState } from 'react'
import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
Expand Down Expand Up @@ -202,19 +202,19 @@ export function ContentTabs({
</span>
</>
)}
{entry.lapse_url && (
<>
{entry.lapse_urls.map((url, i) => (
<Fragment key={url}>
<span className="text-muted-foreground">·</span>
<a
href={entry.lapse_url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-foreground hover:underline"
>
Lapse ↗
Lapse{entry.lapse_urls.length > 1 ? ` ${i + 1}` : ''} ↗
</a>
</>
)}
</Fragment>
))}
{entry.content_lost && <Badge variant="destructive">Content vanished</Badge>}
{!entry.content_lost && !entry.meets_requirements && <Badge variant="warning">Below requirements</Badge>}
{!isGitJournal && <CopyDevlogLink url={`${origin}/projects/${project.id}/devlogs/${entry.id}`} />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function JournalDeflationTable({
<span className="flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground">
<span>{entry.created_at}</span>
{isGroupProject && <span>· {entry.user_display_name}</span>}
{entry.lapse_url && (
{entry.lapse_urls.length > 0 && (
<span className="flex items-center gap-0.5">
· <Film className="size-2.5" /> lapse
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ function WeekRow({
{item.entry_date} · {item.chars} chars
{!item.has_image && ' · no image'}
{item.backfilled && ` · written ${item.written_on}`}
{item.lapse_url && ' · timelapse'}
{item.lapse_urls.length > 0 && ' · timelapse'}
</span>
</a>
<span
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/components/admin/review/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export interface ReviewDevlog {
content: string
time_spent: string | null
time_hours: number | null
lapse_url: string | null
lapse_urls: string[]
created_at: string
created_at_iso: string
user_id: number
Expand Down Expand Up @@ -101,7 +101,7 @@ export interface JournalDigestItem {
hours: number
chars: number
has_image: boolean
lapse_url: string | null
lapse_urls: string[]
written_on: string
backfilled: boolean
outlier: boolean
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/lib/justificationPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function dateRange(isoValues: (string | null | undefined)[]): string {

export function buildTimeEvidence(
project: {
devlogs: { created_at_iso: string; lapse_url: string | null }[]
devlogs: { created_at_iso: string; lapse_urls: string[] }[]
devlog_hours: number
commits_url: string | null
},
Expand All @@ -93,7 +93,7 @@ export function buildTimeEvidence(
`${count} journal ${count === 1 ? 'entry' : 'entries'}${range}, ${formatHours(project.devlog_hours)} logged.`,
)

const timelapses = project.devlogs.filter((d) => (d.lapse_url ?? '').trim()).length
const timelapses = project.devlogs.reduce((sum, d) => sum + d.lapse_urls.length, 0)
if (timelapses > 0) parts.push(`${timelapses} timelapse ${timelapses === 1 ? 'link' : 'links'} attached.`)
}

Expand Down
31 changes: 31 additions & 0 deletions app/javascript/lib/timeSpent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const round2 = (n: number) => Math.round(n * 100) / 100

// Mirrors lib/time_spent_parser.rb so the preview matches what the server stores.
export function parseTimeSpent(input: string): number | null {
const value = input.trim().toLowerCase()
if (!value) return null

let m = value.match(/(\d+(?:\.\d+)?)\s*h(?:ours?)?\s*[,\s]*(\d+(?:\.\d+)?)\s*m?(?:in(?:utes?)?)?/)
if (m) return round2(parseFloat(m[1]) + parseFloat(m[2]) / 60)

m = value.match(/^(\d+):(\d+)$/)
if (m) return round2(parseInt(m[1], 10) + parseInt(m[2], 10) / 60)

m = value.match(/(\d+(?:\.\d+)?)\s*h(?:ours?)?/)
if (m) return round2(parseFloat(m[1]))

m = value.match(/(\d+(?:\.\d+)?)\s*m(?:in(?:utes?)?)?/)
if (m) return round2(parseFloat(m[1]) / 60)

if (/^\d+(?:\.\d+)?$/.test(value)) return round2(parseFloat(value))

return null
}

export function formatHours(hours: number): string {
const totalMinutes = Math.round(hours * 60)
const h = Math.floor(totalMinutes / 60)
const m = totalMinutes % 60
if (h === 0) return `${m}m`
return m === 0 ? `${h}h` : `${h}h ${m}m`
}
9 changes: 5 additions & 4 deletions app/javascript/pages/Admin/Projects/Show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -620,17 +620,18 @@ export default function AdminProjectsShow({
{entry.time_spent}
</span>
)}
{entry.lapse_url && (
{entry.lapse_urls.map((url, i) => (
<a
href={entry.lapse_url}
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-foreground hover:underline"
>
<ExternalLink className="size-3" />
Lapse
Lapse{entry.lapse_urls.length > 1 ? ` ${i + 1}` : ''}
</a>
)}
))}
<span className="text-muted-foreground">· {entry.created_at}</span>
</div>
<div className="markdown-content text-sm">
Expand Down
6 changes: 3 additions & 3 deletions app/javascript/pages/Admin/Reviews/Show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ export default function AdminReviewsShow({
approved < claimed - 0.001
? `claimed ${claimed.toFixed(1)}h, approved ${approved.toFixed(1)}h`
: `${claimed.toFixed(1)}h approved in full`
const lapse = e.lapse_url ? ` — timelapse: ${e.lapse_url}` : ''
const lapse = e.lapse_urls.length > 0 ? ` — timelapse: ${e.lapse_urls.join(', ')}` : ''
return `${window.location.origin}/projects/${project.id}#devlog-${e.id} — ${e.created_at}: ${e.title} (${verdict})${lapse}`
})
.join('\n'),
Expand All @@ -403,7 +403,7 @@ export default function AdminReviewsShow({
approved_hours: approvedHours,
repo_link: project.repo_link,
devlog_count: project.devlogs.length,
timelapse_urls: [...new Set(project.devlogs.map((d) => (d.lapse_url ?? '').trim()).filter(Boolean))],
timelapse_urls: [...new Set(project.devlogs.flatMap((d) => d.lapse_urls))],
public_url: `${origin}/projects/${project.id}`,
admin_url: `${origin}/admin/projects/${project.id}`,
time_summary: timeSummary,
Expand Down Expand Up @@ -578,7 +578,7 @@ export default function AdminReviewsShow({
}
}, [project.id, justificationPreview, approvedHours, track])

const hasLapse = useMemo(() => project.devlogs.some((d) => (d.lapse_url ?? '').trim().length > 0), [project.devlogs])
const hasLapse = useMemo(() => project.devlogs.some((d) => d.lapse_urls.length > 0), [project.devlogs])

// Runs on every keystroke against the rule table the server sent, so the
// reviewer sees the problem while writing. The server re-runs the same rules
Expand Down
13 changes: 7 additions & 6 deletions app/javascript/pages/Devlogs/Show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface Props {
content: string
time_spent: string | null
time_hours: number | null
lapse_url: string | null
lapse_urls: string[]
created_at: string
}
can_edit: boolean
Expand All @@ -38,25 +38,26 @@ export default function DevlogsShow({ project, devlog }: Props) {
</Link>

<header className="ghost-border bg-[#1c1b1b] p-8 mb-8">
{(devlog.time_spent || devlog.lapse_url) && (
{(devlog.time_spent || devlog.lapse_urls.length > 0) && (
<div className="flex items-center gap-3 mb-4 flex-wrap">
{devlog.time_spent && (
<span className="text-[#ffb595] text-xs flex items-center gap-1">
<span className="material-symbols-outlined text-sm">schedule</span>
{devlog.time_spent}
</span>
)}
{devlog.lapse_url && (
{devlog.lapse_urls.map((url, i) => (
<a
href={devlog.lapse_url}
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-[#ffb595] hover:text-[#ca5924] text-xs flex items-center gap-1"
>
<span className="material-symbols-outlined text-sm">videocam</span>
Lapse
Lapse{devlog.lapse_urls.length > 1 ? ` ${i + 1}` : ''}
</a>
)}
))}
</div>
)}

Expand Down
Loading
Loading