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
13 changes: 12 additions & 1 deletion app/controllers/path_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ class PathController < ApplicationController
next 0 unless current_user && !current_user.trial?
StreakDay.current_streak(current_user)
}
inertia_share unsubmitted_hours: -> { # Drives the "logged but not submitted" nudge banner on the path page
next nil unless current_user && !current_user.trial? # Qualification is a full-user concept; trial users see the sign-up CTA instead
next nil if current_user.ships.exists? # Already submitted at least one ship — nothing to nudge
next nil if current_logged_seconds < 20 * 3600 # Below the 20h nudge floor
(current_logged_seconds / 3600.0).round
}

def index
mail_intro_id = deliver_mail_intro || deliver_auto_open_mail
Expand Down Expand Up @@ -48,6 +54,11 @@ def index

private

# Memoized so the unsubmitted_hours share and pending_dialog_key compute the batch hours query at most once per request.
def current_logged_seconds
@current_logged_seconds ||= current_user.total_time_logged_seconds
end

NUDGE_INTERVAL_DAYS = 12

def deliver_auto_open_mail
Expand Down Expand Up @@ -90,7 +101,7 @@ def pending_dialog_key(journal_entries)
sixty = campaigns_by_key["sixty_hours"]
return "sixty_hours" if sixty && !sixty.seen?

if sixty.nil? && current_user.total_time_logged_seconds >= 60 * 3600
if sixty.nil? && current_logged_seconds >= 60 * 3600
current_user.dialog_campaigns.create!(key: "sixty_hours")
return "sixty_hours"
end
Expand Down
29 changes: 16 additions & 13 deletions app/frontend/components/announcements/AnnouncementCard.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { motion, type Variants } from 'motion/react'
import { ModalLink } from '@inertiaui/modal-react'
import type { Announcement, AnnouncementKind } from './types'
import { dismissAnnouncement } from './storage'

type Props = {
announcement: Announcement
index: number
stacked: boolean
isOnly: boolean
}

const STYLES: Record<AnnouncementKind, string> = {
critical:
'bg-brown text-beige py-2 px-3 lg:px-6 text-sm sm:text-lg shadow-[0_3px_0_rgba(97,69,58,0.35)] underline decoration-1 underline-offset-2 hover:bg-light-brown hover:text-dark-brown',
info: 'bg-light-brown text-dark-brown py-2 px-3 lg:px-5 text-sm sm:text-base shadow-[0_2px_0_rgba(97,69,58,0.3)] hover:bg-yellow',
promo:
'bg-yellow text-dark-brown py-1.5 px-3 lg:px-5 text-xs sm:text-sm shadow-[0_2px_0_rgba(97,69,58,0.25)] hover:bg-light-brown',
'bg-brown text-beige py-1.5 px-3 text-xs sm:text-sm shadow-[0_3px_0_rgba(97,69,58,0.35)] underline decoration-1 underline-offset-2 hover:bg-light-brown hover:text-dark-brown',
info: 'bg-light-brown text-dark-brown py-1.5 px-3 text-xs sm:text-sm shadow-[0_2px_0_rgba(97,69,58,0.3)] hover:bg-yellow',
promo: 'bg-yellow text-dark-brown py-1 px-3 text-xs shadow-[0_2px_0_rgba(97,69,58,0.25)] hover:bg-light-brown',
}

const TACK_COLOR: Record<AnnouncementKind, string> = {
Expand All @@ -37,12 +36,12 @@ const cardVariants: Variants = {
exit: { opacity: 0, y: -8, scale: 0.96, transition: { duration: 0.18 } },
}

export default function AnnouncementCard({ announcement, index, stacked, isOnly }: Props) {
export default function AnnouncementCard({ announcement, index, stacked }: Props) {
const tilt = tiltFor(index, stacked)
const isCritical = announcement.kind === 'critical'

const baseClass =
'relative pointer-events-auto block border border-dark-brown rounded-xs transition-colors text-center font-medium overflow-hidden'
'relative pointer-events-auto block border border-dark-brown rounded-xs transition-colors text-left font-medium overflow-hidden'
const styleClass = STYLES[announcement.kind]
const paddingShift = isCritical ? 'pl-4' : '' // Make room for the coral accent stripe.

Expand All @@ -55,8 +54,8 @@ export default function AnnouncementCard({ announcement, index, stacked, isOnly
className={`absolute -top-1.5 left-1/2 -translate-x-1/2 size-2.5 rounded-full ring-1 ring-dark-brown/40 shadow-[0_1px_0_rgba(97,69,58,0.5)] ${TACK_COLOR[announcement.kind]}`}
/>
)}
<span className="inline-flex items-center justify-center gap-2">
<span>{announcement.message}</span>
<span className="flex items-center justify-between gap-2">
<span className="min-w-0">{announcement.message}</span>
{announcement.dismissible && (
<button
type="button"
Expand All @@ -75,7 +74,7 @@ export default function AnnouncementCard({ announcement, index, stacked, isOnly
</>
)

const widthClass = isOnly ? 'mx-auto w-full xs:w-fit' : 'w-full'
const widthClass = 'w-full'

return (
<motion.li
Expand All @@ -89,7 +88,13 @@ export default function AnnouncementCard({ announcement, index, stacked, isOnly
whileHover={stacked ? { rotate: 0, y: -2 } : { y: -1 }}
className={widthClass}
>
{announcement.href ? (
{!announcement.href ? (
<div className={`${baseClass} ${styleClass} ${paddingShift}`}>{content}</div>
) : announcement.modal ? (
<ModalLink href={announcement.href} className={`${baseClass} ${styleClass} ${paddingShift}`}>
{content}
</ModalLink>
) : (
<a
href={announcement.href}
target={announcement.external ? '_blank' : undefined}
Expand All @@ -98,8 +103,6 @@ export default function AnnouncementCard({ announcement, index, stacked, isOnly
>
{content}
</a>
) : (
<div className={`${baseClass} ${styleClass} ${paddingShift}`}>{content}</div>
)}
</motion.li>
)
Expand Down
16 changes: 10 additions & 6 deletions app/frontend/components/announcements/AnnouncementsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,35 @@ import type { Announcement, AnnouncementKind } from './types'
import AnnouncementCard from './AnnouncementCard'
import Twine from './Twine'
import { useIdentityAnnouncement } from './useIdentityAnnouncement'
import { useUnsubmittedHoursAnnouncement } from './useUnsubmittedHoursAnnouncement'
import { useFeedbackAnnouncement } from './useFeedbackAnnouncement'

const ORDER: Record<AnnouncementKind, number> = { critical: 0, info: 1, promo: 2 }

export default function AnnouncementsBar() {
const identity = useIdentityAnnouncement()
const unsubmittedHours = useUnsubmittedHoursAnnouncement()
const feedback = useFeedbackAnnouncement()

const announcements = useMemo<Announcement[]>(
() =>
[identity, feedback].filter((a): a is Announcement => a !== null).sort((a, b) => ORDER[a.kind] - ORDER[b.kind]),
[identity, feedback],
[identity, unsubmittedHours, feedback]
.filter((a): a is Announcement => a !== null)
.sort((a, b) => ORDER[a.kind] - ORDER[b.kind]),
[identity, unsubmittedHours, feedback],
)

if (announcements.length === 0) return null
const stacked = announcements.length > 1

return (
<div className="announcements-bar pointer-events-none flex justify-center">
<div className="relative pointer-events-auto w-full xs:w-fit max-w-3xl">
<div className="announcements-bar pointer-events-none ml-auto w-full max-w-[18rem] sm:max-w-xs">
<div className="relative pointer-events-auto w-full">
{stacked && <Twine />}
<motion.ul layout className="relative flex flex-col items-stretch gap-2 xs:gap-3">
<motion.ul layout className="relative flex flex-col items-stretch gap-2">
<AnimatePresence initial={false}>
{announcements.map((a, i) => (
<AnnouncementCard key={a.id} announcement={a} index={i} stacked={stacked} isOnly={!stacked} />
<AnnouncementCard key={a.id} announcement={a} index={i} stacked={stacked} />
))}
</AnimatePresence>
</motion.ul>
Expand Down
1 change: 1 addition & 0 deletions app/frontend/components/announcements/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export interface Announcement {
message: string
href?: string
external?: boolean
modal?: boolean
dismissible?: boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { usePage } from '@inertiajs/react'
import type { SharedProps } from '@/types'
import type { Announcement } from './types'

export function useUnsubmittedHoursAnnouncement(): Announcement | null {
const { unsubmitted_hours } = usePage<SharedProps>().props
if (!unsubmitted_hours) return null

return {
id: 'unsubmitted-hours',
kind: 'info',
message: `You've logged ${unsubmitted_hours} hours — submit a project to count them toward qualification.`,
href: '/projects',
modal: true,
dismissible: false,
}
}
80 changes: 42 additions & 38 deletions app/frontend/components/path/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { usePage, router } from '@inertiajs/react'
import { useModalStack } from '@inertiaui/modal-react'
import type { SharedProps } from '@/types'
import { notify } from '@/lib/notifications'
import AnnouncementsBar from '@/components/announcements/AnnouncementsBar'

type Props = {
koiBalance: number
Expand Down Expand Up @@ -80,45 +81,48 @@ export default function Header({ koiBalance, goldBalance, avatar, displayName }:
</div>
</div>

<div className="flex flex-col-reverse xs:flex-row items-end space-x-2 sm:space-x-8 xs:items-center">
<button
type="button"
onClick={() => visitModal('/streak_goal')}
className="flex flex-row-reverse xs:flex-row items-center space-x-1 cursor-pointer group relative"
aria-label="Streak"
>
<img src="/fire.svg" alt="streak" className="h-8 xl:h-10" />
<span className="text-coral text-3xl xs:text-4xl xl:text-5xl font-bold">{shared.current_streak}</span>
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-dark-brown px-2 py-1 text-xs text-light-brown opacity-0 transition-opacity group-hover:opacity-100">
Streak
</span>
</button>
<button
type="button"
onClick={() => visitModal('/streak_goal')}
className="flex flex-row-reverse xs:flex-row items-center space-x-1 cursor-pointer group relative"
aria-label="Streak freezes"
>
<img src="/frozen-fire.svg" alt="streak freeze" className="h-8 xl:h-10" />
<span className="text-ice-blue text-3xl xs:text-4xl xl:text-5xl font-bold">{shared.streak_freezes}</span>
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-dark-brown px-2 py-1 text-xs text-light-brown opacity-0 transition-opacity group-hover:opacity-100">
Streak Freezes
</span>
</button>
<div className="flex flex-row-reverse xs:flex-row items-center space-x-2 group relative">
<img src="/koifish.webp" alt="koi" className="h-8 xs:h-10" />
<span className="text-coral text-3xl xs:text-4xl xl:text-5xl font-bold">{koiBalance}</span>
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-dark-brown px-2 py-1 text-xs text-light-brown opacity-0 transition-opacity group-hover:opacity-100">
Koi Balance
</span>
</div>
<div className="flex flex-row-reverse xs:flex-row items-center space-x-2 group relative">
<img src="/gold.webp" alt="gold" className="h-8 xs:h-10" />
<span className="text-yellow-600 text-3xl xs:text-4xl xl:text-5xl font-bold">{goldBalance}</span>
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-dark-brown px-2 py-1 text-xs text-light-brown opacity-0 transition-opacity group-hover:opacity-100">
Gold Balance
</span>
<div className="flex flex-col items-end gap-2 max-w-full min-w-0">
<div className="flex flex-col-reverse xs:flex-row items-end space-x-2 sm:space-x-8 xs:items-center">
<button
type="button"
onClick={() => visitModal('/streak_goal')}
className="flex flex-row-reverse xs:flex-row items-center space-x-1 cursor-pointer group relative"
aria-label="Streak"
>
<img src="/fire.svg" alt="streak" className="h-8 xl:h-10" />
<span className="text-coral text-3xl xs:text-4xl xl:text-5xl font-bold">{shared.current_streak}</span>
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-dark-brown px-2 py-1 text-xs text-light-brown opacity-0 transition-opacity group-hover:opacity-100">
Streak
</span>
</button>
<button
type="button"
onClick={() => visitModal('/streak_goal')}
className="flex flex-row-reverse xs:flex-row items-center space-x-1 cursor-pointer group relative"
aria-label="Streak freezes"
>
<img src="/frozen-fire.svg" alt="streak freeze" className="h-8 xl:h-10" />
<span className="text-ice-blue text-3xl xs:text-4xl xl:text-5xl font-bold">{shared.streak_freezes}</span>
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-dark-brown px-2 py-1 text-xs text-light-brown opacity-0 transition-opacity group-hover:opacity-100">
Streak Freezes
</span>
</button>
<div className="flex flex-row-reverse xs:flex-row items-center space-x-2 group relative">
<img src="/koifish.webp" alt="koi" className="h-8 xs:h-10" />
<span className="text-coral text-3xl xs:text-4xl xl:text-5xl font-bold">{koiBalance}</span>
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-dark-brown px-2 py-1 text-xs text-light-brown opacity-0 transition-opacity group-hover:opacity-100">
Koi Balance
</span>
</div>
<div className="flex flex-row-reverse xs:flex-row items-center space-x-2 group relative">
<img src="/gold.webp" alt="gold" className="h-8 xs:h-10" />
<span className="text-yellow-600 text-3xl xs:text-4xl xl:text-5xl font-bold">{goldBalance}</span>
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-dark-brown px-2 py-1 text-xs text-light-brown opacity-0 transition-opacity group-hover:opacity-100">
Gold Balance
</span>
</div>
</div>
<AnnouncementsBar />
</div>
</header>
)
Expand Down
2 changes: 0 additions & 2 deletions app/frontend/pages/path/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import SignUpCta from '@/components/path/SignUpCta'
import BgmPlayer from '@/components/path/BgmPlayer'
import Header from '@/components/path/Header'
import SubmissionCountdown from '@/components/path/SubmissionCountdown'
import AnnouncementsBar from '@/components/announcements/AnnouncementsBar'
import FlashMessages from '@/components/FlashMessages'
import { notify } from '@/lib/notifications'
import { useLiveReload } from '@/lib/useLiveReload'
Expand Down Expand Up @@ -470,7 +469,6 @@ export default function PathIndex() {
style={{ pointerEvents: pathIntro.hudVisible ? 'auto' : 'none' }}
>
<Header koiBalance={user.koi} goldBalance={user.gold} avatar={user.avatar} displayName={user.display_name} />
<AnnouncementsBar />
</motion.div>

<motion.div
Expand Down
1 change: 1 addition & 0 deletions app/frontend/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface SharedProps {
rsvp_path: string
has_unread_mail: boolean
current_streak: number
unsubmitted_hours: number | null
streak_freezes: number
Comment on lines 43 to 46
identity_gate: IdentityGate | null
show_feedback_banner: boolean
Expand Down
7 changes: 5 additions & 2 deletions app/services/you_tube_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,12 @@ def fetch_video_data_from_oembed(video_id, url: nil)

def parse_iso8601_duration(duration_string)
return nil if duration_string.blank?
match = duration_string.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/)
# YouTube uses a days component for videos >= 24h (e.g. a 30h video is "P1DT6H1S"),
# which the old PT-only regex couldn't match.
match = duration_string.match(/\AP(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?\z/)
return nil unless match
(match[1].to_i * 3600) + (match[2].to_i * 60) + match[3].to_i
days, hours, minutes, seconds = match.captures.map(&:to_i)
(days * 86_400) + (hours * 3_600) + (minutes * 60) + seconds
Comment on lines +169 to +174
end

def youtube_api_key
Expand Down
Loading